diff --git a/packages/react-native/README.md b/packages/react-native/README.md index 3c09c67f..9b1cd585 100644 --- a/packages/react-native/README.md +++ b/packages/react-native/README.md @@ -195,6 +195,76 @@ Any prop value can be a dynamic expression resolved at render time: See [@json-render/core](../core/README.md) for full expression syntax. +### Computed functions + +Register named functions through `JSONUIProvider` or a component returned by `createRenderer` to resolve `$computed` expressions: + +```tsx + `${args.first} ${args.last}` }} +> + + +``` + +### Custom directives + +Register custom directives through `JSONUIProvider` or a component returned by `createRenderer` to resolve user-defined `$`-prefixed values in component props: + +```tsx +import { defineDirective, resolvePropValue } from "@json-render/core"; +import { z } from "zod"; + +const math = defineDirective({ + name: "$math", + schema: z.object({ + $math: z.literal("multiply"), + a: z.unknown(), + b: z.unknown(), + }), + resolve(value, ctx) { + return Number(resolvePropValue(value.a, ctx)) * + Number(resolvePropValue(value.b, ctx)); + }, +}); + +const format = defineDirective({ + name: "$format", + schema: z.object({ + $format: z.literal("currency"), + value: z.unknown(), + currency: z.string(), + }), + resolve(value, ctx) { + const amount = Number(resolvePropValue(value.value, ctx)); + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: value.currency, + }).format(amount); + }, +}); + + + +; +``` + +The directives compose in props, so `$format` can format a `$math` result that reads its operands from state: + +```json +{ + "$format": "currency", + "value": { + "$math": "multiply", + "a": { "$state": "/price" }, + "b": { "$state": "/qty" } + }, + "currency": "USD" +} +``` + +See the [directives documentation](https://json-render.dev/docs/directives) for more details. + ## Tab Navigation Pattern Combine `Pressable`, `setState`, visibility conditions, and dynamic props for functional tabs: diff --git a/packages/react-native/src/renderer.tsx b/packages/react-native/src/renderer.tsx index f9601a8b..3601918c 100644 --- a/packages/react-native/src/renderer.tsx +++ b/packages/react-native/src/renderer.tsx @@ -12,6 +12,9 @@ import type { Catalog, SchemaDefinition, StateStore, + ComputedFunction, + DirectiveDefinition, + DirectiveRegistry, } from "@json-render/core"; import { resolveElementProps, @@ -19,6 +22,7 @@ import { resolveActionParam, evaluateVisibility, getByPath, + createDirectiveRegistry, type PropResolutionContext, type VisibilityContext as CoreVisibilityContext, } from "@json-render/core"; @@ -40,6 +44,23 @@ import { ConfirmDialog } from "./contexts/actions"; import { standardComponents } from "./components/standard"; import { RepeatScopeProvider, useRepeatScope } from "./contexts/repeat-scope"; +const EMPTY_FUNCTIONS: Record = {}; + +const FunctionsContext = + React.createContext>(EMPTY_FUNCTIONS); + +function useFunctions(): Record { + return React.useContext(FunctionsContext); +} + +const DirectivesContext = React.createContext( + undefined, +); + +function useDirectives(): DirectiveRegistry | undefined { + return React.useContext(DirectivesContext); +} + /** * Props passed to component renderers */ @@ -159,6 +180,8 @@ const ElementRenderer = React.memo(function ElementRenderer({ const { ctx } = useVisibility(); const { execute } = useActions(); const { getSnapshot } = useStateStore(); + const functions = useFunctions(); + const directives = useDirectives(); // Build context with repeat scope (used for both visibility and props) const fullCtx: PropResolutionContext = useMemo( @@ -169,9 +192,11 @@ const ElementRenderer = React.memo(function ElementRenderer({ repeatItem: repeatScope.item, repeatIndex: repeatScope.index, repeatBasePath: repeatScope.basePath, + functions, + directives, } - : ctx, - [ctx, repeatScope], + : { ...ctx, functions, directives }, + [ctx, repeatScope, functions, directives], ); // Evaluate visibility (now supports $item/$index inside repeat scopes) @@ -439,6 +464,10 @@ export interface JSONUIProviderProps { string, (value: unknown, args?: Record) => boolean >; + /** Named functions for `$computed` expressions in props */ + functions?: Record; + /** Custom directives for user-defined `$`-prefixed dynamic values */ + directives?: DirectiveDefinition[]; /** Callback when state changes (uncontrolled mode) */ onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void; children: ReactNode; @@ -454,9 +483,16 @@ export function JSONUIProvider({ handlers, navigate, validationFunctions, + functions, + directives, onStateChange, children, }: JSONUIProviderProps) { + const directiveRegistry = useMemo( + () => (directives ? createDirectiveRegistry(directives) : undefined), + [directives], + ); + return ( - - {children} - - + + + + {children} + + + + @@ -663,6 +703,10 @@ export interface CreateRendererProps { onAction?: (actionName: string, params?: Record) => void; /** Callback when state changes (uncontrolled mode) */ onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void; + /** Named functions for `$computed` expressions in props */ + functions?: Record; + /** Custom directives for user-defined `$`-prefixed dynamic values */ + directives?: DirectiveDefinition[]; /** Whether the spec is currently loading/streaming */ loading?: boolean; /** Fallback component for unknown types */ @@ -716,9 +760,16 @@ export function createRenderer< state, onAction, onStateChange, + functions, + directives, loading, fallback, }: CreateRendererProps) { + const directiveRegistry = useMemo( + () => (directives ? createDirectiveRegistry(directives) : undefined), + [directives], + ); + // Wrap onAction with a Proxy so any action name routes to the callback const actionHandlers = onAction ? new Proxy( @@ -744,15 +795,19 @@ export function createRenderer< > - - - - + + + + + + + + diff --git a/skills/react-native/SKILL.md b/skills/react-native/SKILL.md index f43134b8..b31ad49d 100644 --- a/skills/react-native/SKILL.md +++ b/skills/react-native/SKILL.md @@ -131,6 +131,20 @@ Any prop value can be a data-driven expression resolved at render time: Components do not use a `statePath` prop for two-way binding. Use `{ "$bindState": "/path" }` on the natural value prop instead. +### Computed functions + +Pass named functions to `JSONUIProvider` or a component returned by `createRenderer` with the `functions` prop. These functions resolve `$computed` expressions in element props. + +### Custom directives + +Pass custom directive definitions to `JSONUIProvider` or a component returned by `createRenderer` with the `directives` prop. Their resolvers can compose with built-in dynamic values such as `$state`: + +```tsx + + + +``` + ## Built-in Actions The `setState` action is handled automatically by `ActionProvider` and updates the state model directly, which re-evaluates visibility conditions and dynamic prop expressions: