Skip to content

defineWidget

Registers widget metadata including accepted props, binding mode, and triggers.

See Widgets concept for how widgets compose into pages. See Built-in Widgets for the widgets that ship with Rangka.

Signature

typescript
import { defineWidget } from '@rangka/shared';

const meta = defineWidget({
  name: 'sales.pipeline-board',
  label: 'Pipeline Board',
  category: 'display',
  schema: {
    showLabels: { type: 'boolean', default: true },
    columnField: { type: 'string', required: true },
  },
  binding: 'none',
  triggers: ['dealSelect'],
  container: false,
});

function PipelineBoard({ props, on }: WidgetProps) {
  return (
    <div className="flex flex-col gap-4 bg-card rounded-lg p-4">
      <h2 className="text-sm font-medium text-muted-foreground">Pipeline</h2>
      {/* widget implementation */}
    </div>
  );
}

export default { meta, component: PipelineBoard };

The widget file exports a default object with meta and component. The framework handles registration at runtime.

WidgetDefinitionMeta

typescript
interface WidgetDefinitionMeta {
  name: string;
  label: string;
  category: 'input' | 'display' | 'layout' | 'action' | 'data';
  schema: Record<string, WidgetPropSchema>;
  binding: 'none' | 'field' | 'expression' | 'model';
  triggers: string[];
  container: boolean;
  accepts?: string[];
}

Fields

FieldTypeRequiredDescription
namestringyesUnique identifier. Used in WidgetNode.type.
labelstringyesDisplay name for the visual editor.
categoryenumyesGrouping for the visual editor palette.
schemaobjectyesProps the widget accepts. Validated on the client.
bindingenumyesWhat data binding mode this widget supports.
triggersstring[]yesEvents this widget can emit. Empty array if none.
containerbooleanyesWhether this widget can hold children.
acceptsstring[]noIf container, restricts allowed child widget types.

Schema

The schema declares what props a widget accepts. The client validates props passed in page definitions against this schema at render time. The server validates container, accepts, triggers, and binding but not individual prop types.

typescript
interface WidgetPropSchema {
  type: 'string' | 'number' | 'boolean' | 'enum' | 'object' | 'array';
  required?: boolean;
  default?: unknown;
  options?: string[];
}
PropertyTypeDescription
typestringData type of the prop value.
requiredbooleanWhether the prop must be provided. Default: false.
defaultunknownDefault value if not provided.
optionsstring[]Allowed values (for enum type).

Binding modes

ModeMeaning
noneNo data connection. Renders from props only.
fieldBinds to a single field on the nearest record in scope.
expressionBinds to a computed expression. Read-only.
modelFetches its own data from a model. Creates a new context scope.

WidgetProps

What the component receives at runtime.

typescript
interface WidgetProps {
  props: Record<string, any>;
  bind: {
    value: any;
    setValue?: (val: any) => void;
    meta?: {
      type: string;
      label: string;
      required: boolean;
      options?: any[];
      readOnly: boolean;
    };
  };
  on: Record<string, (...args: any[]) => void>;
  context: {
    record: Record<string, any>;
    model: string;
    mode: 'view' | 'edit';
    index?: number;
  };
  children?: ReactNode;
}
PropDescription
propsStatic props from the widget node, validated against schema.
bind.valueCurrent value from the bound field or expression.
bind.setValueSetter for the bound field. Only present for writable bindings.
bind.metaField metadata from the model (type, label, required, options).
onTrigger callbacks. Call on.click() or on.rowClick(data) to fire.
contextCurrent data context (record, model name, mode, iteration index).
childrenRendered child widgets. Only present if container: true.

Resolution

Widgets are resolved from the registry by type name. Built-in widgets are registered at boot. Custom widgets are loaded on demand from the manifest when first encountered.

If no widget is registered and no manifest entry exists for a given type, the renderer shows an error placeholder.

File location

Custom widgets live in your module's widgets/ directory:

modules/sales/
└── widgets/
    └── pipeline-board.tsx

They are compiled by rangka build into .rangka/ and loaded at runtime via dynamic import().

Build and runtime

Running rangka build does the following for each widget file:

  1. Bundles the widget with esbuild (React is provided by the shell, npm deps are bundled in)
  2. Generates scoped Tailwind CSS for utility classes used in the widget
  3. Writes the bundle and CSS to .rangka/widgets/
  4. Produces a manifest.json mapping widget keys to bundle paths

At runtime, the shell fetches the manifest and lazily imports widgets when a page references them. CSS is injected via <link> elements before the component renders.

Using Tailwind

Custom widgets can use any Tailwind utility class. The build generates CSS containing only the classes the widget references. Semantic tokens from the shell theme (bg-primary, text-muted-foreground, border, etc.) work automatically.

tsx
function MyWidget({ props }: WidgetProps) {
  return (
    <div className="rounded-lg border bg-card p-4 hover:shadow-md">
      <span className="text-emerald-600 font-semibold">{props.value}</span>
    </div>
  );
}

Using npm packages

Install any browser-compatible npm package in your project and import it in a widget. The build bundles it into the widget chunk.

tsx
import { BarChart, Bar, XAxis, YAxis } from 'recharts';

function SalesChart({ props }: WidgetProps) {
  return (
    <BarChart data={props.data} width={400} height={300}>
      <XAxis dataKey="month" />
      <YAxis />
      <Bar dataKey="sales" fill="hsl(var(--primary))" />
    </BarChart>
  );
}

Packages that import their own CSS (like @xyflow/react/dist/style.css) are supported. The CSS is extracted and merged with the Tailwind output.

Example

typescript
// modules/sales/widgets/pipeline-board.tsx
import { defineWidget } from '@rangka/shared';

const meta = defineWidget({
  name: 'sales.pipeline-board',
  label: 'Pipeline Board',
  category: 'display',
  schema: {
    showLabels: { type: 'boolean', default: true },
  },
  binding: 'none',
  triggers: ['dealSelect'],
  container: false,
});

function PipelineBoard({ props, on }: any) {
  return (
    <div className="flex flex-col gap-4 bg-card rounded-lg p-4">
      <h3 className="text-sm font-medium">Pipeline</h3>
      {/* implementation */}
      <button onClick={() => on.dealSelect?.('deal-1')}>Select Deal</button>
    </div>
  );
}

export default { meta, component: PipelineBoard };

Use it in a page:

typescript
definePage({
  key: 'sales.pipeline',
  label: 'Pipeline',
  widgets: [
    {
      type: 'sales.pipeline-board',
      props: { showLabels: true },
      on: { dealSelect: { type: 'setValue', field: '$state.selectedId', value: '{{$args.0}}' } },
    },
  ],
});