| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348 |
- import { z } from "zod";
- import type {
- VisibilityCondition,
- StateCondition,
- ItemCondition,
- IndexCondition,
- SingleCondition,
- AndCondition,
- OrCondition,
- StateModel,
- } from "./types";
- import { getByPath } from "./types";
- // =============================================================================
- // Schemas
- // =============================================================================
- /**
- * Schema for a single state condition.
- */
- const numericOrStateRef = z.union([
- z.number(),
- z.object({ $state: z.string() }),
- ]);
- const comparisonOps = {
- eq: z.unknown().optional(),
- neq: z.unknown().optional(),
- gt: numericOrStateRef.optional(),
- gte: numericOrStateRef.optional(),
- lt: numericOrStateRef.optional(),
- lte: numericOrStateRef.optional(),
- not: z.literal(true).optional(),
- };
- const StateConditionSchema = z.object({
- $state: z.string(),
- ...comparisonOps,
- });
- const ItemConditionSchema = z.object({
- $item: z.string(),
- ...comparisonOps,
- });
- const IndexConditionSchema = z.object({
- $index: z.literal(true),
- ...comparisonOps,
- });
- const SingleConditionSchema = z.union([
- StateConditionSchema,
- ItemConditionSchema,
- IndexConditionSchema,
- ]);
- /**
- * Visibility condition schema.
- *
- * Lazy because `OrCondition` can recursively contain `VisibilityCondition`.
- */
- export const VisibilityConditionSchema: z.ZodType<VisibilityCondition> = z.lazy(
- () =>
- z.union([
- z.boolean(),
- SingleConditionSchema,
- z.array(SingleConditionSchema),
- z.object({ $and: z.array(VisibilityConditionSchema) }),
- z.object({ $or: z.array(VisibilityConditionSchema) }),
- ]),
- );
- // =============================================================================
- // Context
- // =============================================================================
- /**
- * Context for evaluating visibility conditions.
- *
- * `repeatItem` and `repeatIndex` are only present inside a `repeat` scope
- * and enable `$item` / `$index` conditions.
- */
- export interface VisibilityContext {
- stateModel: StateModel;
- /** The current repeat item (set inside a repeat scope). */
- repeatItem?: unknown;
- /** The current repeat array index (set inside a repeat scope). */
- repeatIndex?: number;
- }
- // =============================================================================
- // Evaluation
- // =============================================================================
- /**
- * Resolve a comparison value. If it's a `{ $state }` reference, look it up;
- * otherwise return the literal.
- */
- function resolveComparisonValue(
- value: unknown,
- ctx: VisibilityContext,
- ): unknown {
- if (typeof value === "object" && value !== null) {
- if (
- "$state" in value &&
- typeof (value as Record<string, unknown>).$state === "string"
- ) {
- return getByPath(ctx.stateModel, (value as { $state: string }).$state);
- }
- }
- return value;
- }
- /**
- * Type guards for condition sources.
- */
- function isItemCondition(cond: SingleCondition): cond is ItemCondition {
- return "$item" in cond;
- }
- function isIndexCondition(cond: SingleCondition): cond is IndexCondition {
- return "$index" in cond;
- }
- /**
- * Resolve the left-hand-side value of a condition based on its source.
- */
- function resolveConditionValue(
- cond: SingleCondition,
- ctx: VisibilityContext,
- ): unknown {
- if (isIndexCondition(cond)) {
- return ctx.repeatIndex;
- }
- if (isItemCondition(cond)) {
- if (ctx.repeatItem === undefined) return undefined;
- return cond.$item === ""
- ? ctx.repeatItem
- : getByPath(ctx.repeatItem, cond.$item);
- }
- // StateCondition
- return getByPath(ctx.stateModel, (cond as StateCondition).$state);
- }
- /**
- * Evaluate a single condition against the context.
- *
- * When `not` is `true`, the final result is inverted — this applies to
- * whichever operator is present (or to the truthiness check if no operator
- * is given). For example:
- * - `{ $state: "/x", not: true }` → `!Boolean(value)`
- * - `{ $state: "/x", gt: 5, not: true }` → `!(value > 5)`
- */
- function evaluateCondition(
- cond: SingleCondition,
- ctx: VisibilityContext,
- ): boolean {
- const value = resolveConditionValue(cond, ctx);
- let result: boolean;
- // Equality
- if (cond.eq !== undefined) {
- const rhs = resolveComparisonValue(cond.eq, ctx);
- result = value === rhs;
- }
- // Inequality
- else if (cond.neq !== undefined) {
- const rhs = resolveComparisonValue(cond.neq, ctx);
- result = value !== rhs;
- }
- // Greater than
- else if (cond.gt !== undefined) {
- const rhs = resolveComparisonValue(cond.gt, ctx);
- result =
- typeof value === "number" && typeof rhs === "number"
- ? value > rhs
- : false;
- }
- // Greater than or equal
- else if (cond.gte !== undefined) {
- const rhs = resolveComparisonValue(cond.gte, ctx);
- result =
- typeof value === "number" && typeof rhs === "number"
- ? value >= rhs
- : false;
- }
- // Less than
- else if (cond.lt !== undefined) {
- const rhs = resolveComparisonValue(cond.lt, ctx);
- result =
- typeof value === "number" && typeof rhs === "number"
- ? value < rhs
- : false;
- }
- // Less than or equal
- else if (cond.lte !== undefined) {
- const rhs = resolveComparisonValue(cond.lte, ctx);
- result =
- typeof value === "number" && typeof rhs === "number"
- ? value <= rhs
- : false;
- }
- // Truthiness (no operator)
- else {
- result = Boolean(value);
- }
- // `not` inverts the result of any condition
- return cond.not === true ? !result : result;
- }
- /**
- * Type guard for AndCondition
- */
- function isAndCondition(
- condition: VisibilityCondition,
- ): condition is AndCondition {
- return (
- typeof condition === "object" &&
- condition !== null &&
- !Array.isArray(condition) &&
- "$and" in condition
- );
- }
- /**
- * Type guard for OrCondition
- */
- function isOrCondition(
- condition: VisibilityCondition,
- ): condition is OrCondition {
- return (
- typeof condition === "object" &&
- condition !== null &&
- !Array.isArray(condition) &&
- "$or" in condition
- );
- }
- /**
- * Evaluate a visibility condition.
- *
- * - `undefined` → visible
- * - `boolean` → that value
- * - `SingleCondition` → evaluate single condition
- * - `SingleCondition[]` → implicit AND (all must be true)
- * - `AndCondition` → `{ $and: [...] }`, explicit AND
- * - `OrCondition` → `{ $or: [...] }`, at least one must be true
- */
- export function evaluateVisibility(
- condition: VisibilityCondition | undefined,
- ctx: VisibilityContext,
- ): boolean {
- // No condition = visible
- if (condition === undefined) {
- return true;
- }
- // Boolean literal
- if (typeof condition === "boolean") {
- return condition;
- }
- // Array = implicit AND
- if (Array.isArray(condition)) {
- return condition.every((c) => evaluateCondition(c, ctx));
- }
- // Explicit AND condition
- if (isAndCondition(condition)) {
- return condition.$and.every((child) => evaluateVisibility(child, ctx));
- }
- // OR condition
- if (isOrCondition(condition)) {
- return condition.$or.some((child) => evaluateVisibility(child, ctx));
- }
- // Single condition
- return evaluateCondition(condition, ctx);
- }
- // =============================================================================
- // Helpers
- // =============================================================================
- /**
- * Helper to create visibility conditions.
- */
- export const visibility = {
- /** Always visible */
- always: true as const,
- /** Never visible */
- never: false as const,
- /** Visible when state path is truthy */
- when: (path: string): StateCondition => ({ $state: path }),
- /** Visible when state path is falsy */
- unless: (path: string): StateCondition => ({ $state: path, not: true }),
- /** Equality check */
- eq: (path: string, value: unknown): StateCondition => ({
- $state: path,
- eq: value,
- }),
- /** Not equal check */
- neq: (path: string, value: unknown): StateCondition => ({
- $state: path,
- neq: value,
- }),
- /** Greater than */
- gt: (path: string, value: number | { $state: string }): StateCondition => ({
- $state: path,
- gt: value,
- }),
- /** Greater than or equal */
- gte: (path: string, value: number | { $state: string }): StateCondition => ({
- $state: path,
- gte: value,
- }),
- /** Less than */
- lt: (path: string, value: number | { $state: string }): StateCondition => ({
- $state: path,
- lt: value,
- }),
- /** Less than or equal */
- lte: (path: string, value: number | { $state: string }): StateCondition => ({
- $state: path,
- lte: value,
- }),
- /** AND multiple conditions */
- and: (...conditions: VisibilityCondition[]): AndCondition => ({
- $and: conditions,
- }),
- /** OR multiple conditions */
- or: (...conditions: VisibilityCondition[]): OrCondition => ({
- $or: conditions,
- }),
- };
|