/** * Component type definitions. * * Pure type definitions for the component system including the Define namespace, * prop/event/slot/model types, ComponentSetupContext, and ComponentFactory. */ import type { JSXElement } from './jsx-runtime.js'; import type { Model as ModelType } from './model.js'; import { signal } from '@sigx/reactivity'; /** * Extension point for additional component attributes. * Use module augmentation to add attributes to all components: * * @example * ```ts * // In @sigx/server-renderer * declare module '@sigx/runtime-core' { * interface ComponentAttributeExtensions { * 'client:load'?: boolean; * 'client:visible'?: boolean; * 'client:idle'?: boolean; * } * } * ``` */ export interface ComponentAttributeExtensions { // Attributes are added here via module augmentation } /** * The host attributes a component opts into accepting, via `Define.Attrs`. * * EMPTY in core — the platform fills it by augmentation, which is what keeps * runtime-core free of any dependency on a renderer: * * @example * ```ts * // In @sigx/runtime-dom * declare module '@sigx/runtime-core' { * interface ComponentAttributes { * id?: string; * onClick?: (e: MouseEvent) => void; * // … * } * } * ``` * * Unlike {@link ComponentAttributeExtensions}, which widens *every* component, * this is inert until a component intersects it into its own props type. A * component that does not forward its leftover props should not advertise * that it accepts them — a type that compiles and then silently drops the * attribute is the failure mode this exists to prevent. */ export interface ComponentAttributes { // Host attributes are added here by the platform via module augmentation } /** * Namespace for component definition types. * Provides a discoverable API for defining props, events, models, slots, and exposed APIs. * * @example * ```tsx * import { component, type Define } from 'sigx'; * * type ButtonProps = * & Define.Prop<'variant', 'primary' | 'secondary'> * & Define.Prop<'disabled', boolean> * & Define.Event<'click', MouseEvent> * & Define.Slot<'default'>; * * export const Button = component(({ props, slots, emit }) => { * return () => ; * }); * ``` */ export namespace Define { /** * Opt into accepting host attributes — `id`, `class`, `style`, `title`, * `data-*`, `aria-*`, DOM event handlers and the rest of the universal * set the platform declares on {@link ComponentAttributes}. * * Declare it only if the component actually forwards its leftover props * to an element, or the type promises something the component drops: * * @example * ```tsx * type ButtonProps = * & Define.Prop<'variant', 'primary' | 'secondary'> * & Define.Attrs; * * const Button = component(ctx => { * const merged = mergeProps(() => { * const { variant: _v, ...rest } = ctx.props; * return rest; * }, () => ({ class: 'btn' })); * return () => ; * }); * ``` */ export type Attrs = ComponentAttributes; /** * {@link Attrs} for a component that declares a prop of its own with the * same name as a host attribute — the component's declaration wins. * * @example * ```tsx * // `title` here is a heading, not the HTML tooltip attribute. * type DialogProps = Define.WithAttrs>; * ``` */ export type WithAttrs = TOwn & Omit; /** * Define a single prop with type, required/optional status */ export type Prop = Required extends false ? { [K in TName]?: TType } : { [K in TName]: TType }; /** * Define a single custom event with its detail type */ export type Event = { [K in TName]?: EventDefinition; }; /** * Define a 2-way bound model. * * Default form: Model → props.model: Model * Named form: Model<"name", T> → props.name: Model * * @example * ```tsx * type InputProps = Define.Model & Define.Prop<'placeholder', string>; * ``` */ export type Model = TType extends void ? { model?: ModelType; /** @internal Marker for JSX to accept binding tuples */ __modelBindings?: { model: TNameOrType }; } & Define.Event<"update:modelValue", TNameOrType> : TNameOrType extends string ? { [K in TNameOrType]?: ModelType; } & { /** @internal Marker for JSX to accept binding tuples */ __modelBindings?: { [K in TNameOrType]: TType }; } & Define.Event<`update:${TNameOrType}`, TType> : never; /** * Define a slot, optionally with scoped props. * * The declaration is enforced on both sides. A fill supplied as children is * checked against it — a fill expecting props the slot does not declare is * an error, and an unannotated fill parameter is inferred from it — and the * accessor requires the props to be passed: once a slot declares * `TProps`, `slots.x?.()` is an error and `slots.x?.(props)` is the call. * * Scoped props are therefore all-or-nothing per slot; there is no "pass them * or don't" form. A slot whose props are genuinely optional declares them as * optional *members* and is called with an object: * `Define.Slot<'item', { index?: number }>` → `slots.item?.({})`. * * @example * ```tsx * type Props = Define.Slot<'header'> & Define.Slot<'item', { item: T; index: number }>; * ``` */ export type Slot = { __slots?: { // Optional: a slot accessor is present only when the parent // provided content for it, otherwise it reads as `undefined`. [K in TName]?: TProps extends void ? () => JSXElement | JSXElement[] | null : (props: TProps) => JSXElement | JSXElement[] | null } }; /** * Define the public API exposed by a component via `expose()`. * * @example * ```tsx * type Props = Define.Expose<{ reset: () => void; getValue: () => string }>; * ``` */ export type Expose = { __exposed?: { __type: T }; }; } /** * Model binding tuple type - [stateObject, key] for forwarding * The state object can be a Signal or any object with the given key */ export type ModelBinding<_T> = [object, string]; /** * Re-export Model type for convenience */ export type { ModelType as Model }; /** * Extract model binding definitions from a component props type. * Used at JSX level to allow binding tuples for model props. */ type ExtractModelBindings = T extends { __modelBindings?: infer M } ? NonNullable : {}; /** * Map model keys to their JSX prop names. * - "model" stays as "model" (default model) * - Other names become "model:name" (named models) */ type ModelPropName = K extends "model" ? "model" : `model:${K}`; /** * Transform Model props to also accept binding syntax at JSX level. * This allows: model={[state, "value"]} or model={props.model} * For named models: model:title={[state, "title"]} or model:title={() => state.title} */ type ExternalModelProps = { [K in keyof ExtractModelBindings as K extends string ? ModelPropName : never]?: | ModelType[K]> // Forward Model | ModelBinding[K]> // Binding tuple | (() => ExtractModelBindings[K]); // Getter function }; export type EventDefinition = { __eventDetail: T }; /** * Default slot function type — the shape of `slots.default` for a component * that did NOT declare the slot, where nothing is known about scoped props. */ type DefaultSlot = () => JSXElement[]; /** * The declared `default` fill, or `never` if the component declares no * `default` slot. * * Distributive on purpose. A props type can be a union — a discriminated union * of prop shapes is the ordinary way to write one — and `ExtractSlots`'s * conditional distributes, so `TSlots` arrives as a union too. `keyof (A | B)` * is the INTERSECTION of their keys, so a plain `'default' extends keyof TSlots` * reads as false whenever any member lacks the slot, and everything downstream * silently relaxes: the untyped fallback comes back and `children` widens to * `any`. Distributing first asks the question per member instead. */ type DefaultFill = NonNullable extends infer S ? S extends any ? 'default' extends keyof S ? NonNullable : never : never : never; /** * Slots object passed to components. Every slot — `default` included — is a * callable accessor only when the parent provided content for it, and an * unprovided slot reads as `undefined`. So presence is the accessor's own * truthiness (`if (slots.x)`), and the call supplies whatever the slot * declared: `slots.x?.()` for a slot without scoped props, * `slots.x?.(props) ?? fallback` for one with them — on a scoped slot the bare * `slots.x?.()` is a type error, which is the point. * * The untyped `DefaultSlot` fallback applies only when the component declared * no `default` slot. Intersecting it unconditionally used to defeat the * declaration: `DefaultSlot & ((props: P) => …)` is callable BOTH ways, so * `slots.default?.()` compiled even on a slot whose props were declared, and * the fill was handed nothing. That is precisely the call site a declaration * exists to flag. */ export type SlotsObject = { default?: [DefaultFill] extends [never] ? DefaultSlot : DefaultFill } & OmitDefault>; /** * `Omit` that survives a union. `Omit` is `Pick>`, and `keyof` a union is the intersection of its members' keys, so a * bare `Omit` over a union throws away every key the members do not share. */ type OmitDefault = T extends any ? Omit : never; /** * Slot content: what the renderer can turn into nodes, plus render-prop fills * of exactly the shape `TFill` describes. * * `TFill` is threaded through the array case rather than applied only at the * top, because the runtime lets a default slot mix element children with * function children freely — and JSX collects multiple children into an array, * so `{(p) => …}` arrives as one array holding both. */ type SlotContent = | JSXElement | undefined | TFill | readonly SlotContent[]; /** * The `children` type for a component's JSX props. * * A component that declares a `default` slot gets its children checked against * that declaration: ordinary content, or a render-prop fill matching the * declared scoped props. So a fill destructuring props the slot never declares * is an error, and a fill whose parameter disagrees with the declared props is * an error — while a fill written with no annotation has its parameter type * INFERRED from the declaration, which is the point. * * A component that declares no `default` slot keeps `any`. Narrowing there * would be a guess: children are legal without a declaration (they land in the * default slot at runtime either way), and there is nothing to check them * against. */ type SlotChildren = [DefaultFill] extends [never] ? any : SlotContent>; /** * Extract event names from an event definition */ type EventNames = { [K in keyof TEvents]: TEvents[K] extends EventDefinition | undefined ? K : never }[keyof TEvents] & string; /** * Extract event detail type for a specific event name */ type EventDetail> = TEvents extends { [K in TName]?: EventDefinition } ? TDetail : never; /** * Typed emit function for dispatching custom events */ export type EmitFn> = >( eventName: TName, ...args: EventDetail extends void ? [] : [detail: EventDetail] ) => void; /** * Capitalize the first letter of a string */ type Capitalize = S extends `${infer First}${infer Rest}` ? `${Uppercase}${Rest}` : S; /** * Convert events to event handler props (on{EventName}) */ type EventHandlers> = { [K in keyof TEvents as TEvents[K] extends EventDefinition | undefined ? `on${Capitalize}` : never ]?: (detail: TEvents[K] extends EventDefinition | undefined ? D : never) => void; }; /** * Platform registry - platforms add their element type here via declaration merging */ export interface PlatformTypes { // Platforms add: element: HTMLElement (or other element type) } /** Resolves to the platform's element type, or 'any' if not defined */ export type PlatformElement = PlatformTypes extends { element: infer E } ? E : any; /** * Base mount context - platforms can extend this via declaration merging */ export interface MountContext { el: TElement; } /** * Base setup context - platforms can extend this via declaration merging */ export interface SetupContext { // Platforms add properties here via module augmentation } /** * Extract keys from T where undefined is assignable to the value (optional props) */ type _OptionalKeys = { [K in keyof T]: undefined extends T[K] ? K : never }[keyof T]; /** * Type for defaults object - REQUIRES all optional keys to be provided. * Required props (where undefined is not assignable) get type 'never' to prevent setting them. * This ensures you don't forget to add a default when adding a new optional prop. */ type _DefaultsFor = { [K in keyof TProps as undefined extends TProps[K] ? K : never]-?: NonNullable; }; /** * Props type after defaults are applied - all props become required (non-undefined) */ export type PropsWithDefaults = { readonly [K in keyof TProps]-?: K extends keyof D ? NonNullable : TProps[K]; }; /** * Props accessor - a reactive proxy for component props. * Use destructuring with defaults for optional props. * * @example * ```tsx * // Destructure with defaults * const { variant = 'primary', size = 'md' } = ctx.props; * return () => * * // Or spread to forward all props * return () => * ``` */ export type PropsAccessor = { readonly [K in keyof TProps]: TProps[K]; }; export interface ComponentSetupContext< TElement = PlatformElement, TProps extends Record = {}, TEvents extends Record = {}, TRef = any, TSlots = {} > extends SetupContext { el: TElement; signal: typeof signal; /** * Component props - includes regular props and Model objects. * * Models are accessed via props: props.model.value, props.title.value * * @example * ```tsx * // Read model * const value = props.model.value; * * // Write model * props.model.value = "new value"; * * // Forward to child * * * // Forward via context * defineProvide(ctx, () => props.model); * ``` */ props: PropsAccessor; slots: SlotsObject; emit: EmitFn; parent: ComponentSetupContext | null; onMounted(fn: (ctx: MountContext) => void): void; onUnmounted(fn: (ctx: MountContext) => void): void; onCreated(fn: () => void): void; onUpdated(fn: () => void): void; expose(exposed: TRef): void; /** * The current render function. Can be replaced directly for HMR. * @internal Used by HMR - set this, then call update() */ renderFn: ViewFn | null; /** * Force the component to re-render using the current renderFn. * For HMR: first set ctx.renderFn to the new render function, then call update(). */ update(): void; /** * Reload this instance against a new setup body — for HMR only. Disposes * the previous run's onUnmounted cleanups, clears all lifecycle hook lists * (so hot updates don't accumulate hooks), re-runs `setup`, then re-fires * the new created/mounted hooks and re-renders. See core#107. * * @internal Present only in dev builds; `undefined` in production. */ __hmrReload?(setup: SetupFn): void; /** * The `ref` the consumer put on this component's vnode. It is peeled out * of props (see `splitComponentProps`) so a props spread cannot bind it a * second time, and the renderer already delivers the `expose()` value to * it — so a component has no reason to read this. * * @internal The one legitimate use is re-forwarding it to a component * this one wraps and renders in its place, which is what `lazy` does. */ __forwardedRef?: any; } export type ViewFn = () => JSXElement | JSXElement[] | undefined; /** * Type for component setup functions. * Includes Props, Events, Ref, and Slots generics to preserve type information. * Can be sync or async - async setup is awaited on server, runs sync on client hydration. */ export type SetupFn< TProps extends Record = {}, TEvents extends Record = {}, TRef = any, TSlots = {} > = (ctx: ComponentSetupContext) => ViewFn | Promise; export type Ref = { current: T | null } | ((instance: T | null) => void); /** * Extract the exposed API type from a component. * Use this to type variables that will hold a component's exposed interface. * * @example * ```tsx * let api: Exposed; * api = r!} /> * api.exposedMethod(); * ``` */ export type Exposed = T["__ref"]; /** * Extract the ref (exposed) type from a component (includes function ref option). * * @example * ```tsx * const myRef = { current: null } as ComponentRef; * ``` */ export type ComponentRef = Ref; /** * Strip internal type markers from component props for setup context (internal use). * Preserves model keys so components can access props.model, props.title, etc. * * Strips: * - Internal markers: __exposed, __slots, __models, __modelBindings * - JSX model:name syntax markers * - Event markers (update:*) */ type StripInternalMarkers = { [K in keyof T as K extends "__exposed" | "__slots" | "__models" | "__modelBindings" ? never : K extends `model:${string}` ? never // Strip JSX model:name syntax marker : K extends `update:${string}` ? never : K]: T[K]; }; /** * Strip props for JSX external signature. * Same as StripInternalMarkers but also strips model keys so ExternalModelProps * is the sole source (avoiding intersection conflicts between Model and widened union). */ type StripForJSX = { [K in keyof T as K extends "__exposed" | "__slots" | "__models" | "__modelBindings" ? never : K extends `model:${string}` ? never : K extends `update:${string}` ? never : K extends keyof ExtractModelBindings ? never // Strip model keys - ExternalModelProps provides them : K]: T[K]; }; /** * Component options (optional second param) */ export interface ComponentOptions { /** Component name for DevTools debugging */ name?: string; } /** * Slot props type - converts slot definitions to a slots prop object */ type SlotProps = TSlots extends Record ? { slots?: Partial } : {}; /** * Sync binding type - used at JSX level to enable two-way binding * The JSX runtime transforms sync into value + onUpdate:value */ type SyncBinding = [object, string] | (() => T); /** * Sync props - if the component has a 'value' prop, allow 'sync' binding */ type SyncProps = 'value' extends keyof TCombined ? { sync?: SyncBinding } : {}; // Return type for component - the function IS the component export type ComponentFactory, TRef, TSlots> = ((props: StripForJSX>> & EventHandlers & SlotProps & SyncProps & ExternalModelProps & JSX.IntrinsicAttributes & ComponentAttributeExtensions & { ref?: Ref; children?: SlotChildren; }) => JSXElement) & { /** @internal Setup function for the renderer */ __setup: SetupFn, TCombined, TRef, TSlots>; /** @internal Component name for debugging */ __name?: string; /** @internal Stable island identity based on file path (injected by sigxIslandsPlugin) */ __islandId?: string; /** @internal Type brand for props */ __props: StripInternalMarkers; /** @internal Type brand for events */ __events: TCombined; /** @internal Type brand for ref */ __ref: TRef; /** @internal Type brand for slots */ __slots: TSlots; }; /** * Structural constraint type for generic functions that accept any ComponentFactory. * * Uses only the covariant brand properties (not the function signature) to avoid * contravariance issues with `strictFunctionTypes`. Any `ComponentFactory` * satisfies this constraint regardless of its props type. * * @see ComponentFactory */ export type AnyComponentFactory = { (...args: any[]): any; __setup: SetupFn; __props: any; __events: any; __ref: any; __slots: any; };