visibility.ts 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  1. import { z } from "zod";
  2. import type {
  3. VisibilityCondition,
  4. StateCondition,
  5. ItemCondition,
  6. IndexCondition,
  7. SingleCondition,
  8. AndCondition,
  9. OrCondition,
  10. StateModel,
  11. } from "./types";
  12. import { getByPath } from "./types";
  13. // =============================================================================
  14. // Schemas
  15. // =============================================================================
  16. /**
  17. * Schema for a single state condition.
  18. */
  19. const numericOrStateRef = z.union([
  20. z.number(),
  21. z.object({ $state: z.string() }),
  22. ]);
  23. const comparisonOps = {
  24. eq: z.unknown().optional(),
  25. neq: z.unknown().optional(),
  26. gt: numericOrStateRef.optional(),
  27. gte: numericOrStateRef.optional(),
  28. lt: numericOrStateRef.optional(),
  29. lte: numericOrStateRef.optional(),
  30. not: z.literal(true).optional(),
  31. };
  32. const StateConditionSchema = z.object({
  33. $state: z.string(),
  34. ...comparisonOps,
  35. });
  36. const ItemConditionSchema = z.object({
  37. $item: z.string(),
  38. ...comparisonOps,
  39. });
  40. const IndexConditionSchema = z.object({
  41. $index: z.literal(true),
  42. ...comparisonOps,
  43. });
  44. const SingleConditionSchema = z.union([
  45. StateConditionSchema,
  46. ItemConditionSchema,
  47. IndexConditionSchema,
  48. ]);
  49. /**
  50. * Visibility condition schema.
  51. *
  52. * Lazy because `OrCondition` can recursively contain `VisibilityCondition`.
  53. */
  54. export const VisibilityConditionSchema: z.ZodType<VisibilityCondition> = z.lazy(
  55. () =>
  56. z.union([
  57. z.boolean(),
  58. SingleConditionSchema,
  59. z.array(SingleConditionSchema),
  60. z.object({ $and: z.array(VisibilityConditionSchema) }),
  61. z.object({ $or: z.array(VisibilityConditionSchema) }),
  62. ]),
  63. );
  64. // =============================================================================
  65. // Context
  66. // =============================================================================
  67. /**
  68. * Context for evaluating visibility conditions.
  69. *
  70. * `repeatItem` and `repeatIndex` are only present inside a `repeat` scope
  71. * and enable `$item` / `$index` conditions.
  72. */
  73. export interface VisibilityContext {
  74. stateModel: StateModel;
  75. /** The current repeat item (set inside a repeat scope). */
  76. repeatItem?: unknown;
  77. /** The current repeat array index (set inside a repeat scope). */
  78. repeatIndex?: number;
  79. }
  80. // =============================================================================
  81. // Evaluation
  82. // =============================================================================
  83. /**
  84. * Resolve a comparison value. If it's a `{ $state }` reference, look it up;
  85. * otherwise return the literal.
  86. */
  87. function resolveComparisonValue(
  88. value: unknown,
  89. ctx: VisibilityContext,
  90. ): unknown {
  91. if (typeof value === "object" && value !== null) {
  92. if (
  93. "$state" in value &&
  94. typeof (value as Record<string, unknown>).$state === "string"
  95. ) {
  96. return getByPath(ctx.stateModel, (value as { $state: string }).$state);
  97. }
  98. }
  99. return value;
  100. }
  101. /**
  102. * Type guards for condition sources.
  103. */
  104. function isItemCondition(cond: SingleCondition): cond is ItemCondition {
  105. return "$item" in cond;
  106. }
  107. function isIndexCondition(cond: SingleCondition): cond is IndexCondition {
  108. return "$index" in cond;
  109. }
  110. /**
  111. * Resolve the left-hand-side value of a condition based on its source.
  112. */
  113. function resolveConditionValue(
  114. cond: SingleCondition,
  115. ctx: VisibilityContext,
  116. ): unknown {
  117. if (isIndexCondition(cond)) {
  118. return ctx.repeatIndex;
  119. }
  120. if (isItemCondition(cond)) {
  121. if (ctx.repeatItem === undefined) return undefined;
  122. return cond.$item === ""
  123. ? ctx.repeatItem
  124. : getByPath(ctx.repeatItem, cond.$item);
  125. }
  126. // StateCondition
  127. return getByPath(ctx.stateModel, (cond as StateCondition).$state);
  128. }
  129. /**
  130. * Evaluate a single condition against the context.
  131. *
  132. * When `not` is `true`, the final result is inverted — this applies to
  133. * whichever operator is present (or to the truthiness check if no operator
  134. * is given). For example:
  135. * - `{ $state: "/x", not: true }` → `!Boolean(value)`
  136. * - `{ $state: "/x", gt: 5, not: true }` → `!(value > 5)`
  137. */
  138. function evaluateCondition(
  139. cond: SingleCondition,
  140. ctx: VisibilityContext,
  141. ): boolean {
  142. const value = resolveConditionValue(cond, ctx);
  143. let result: boolean;
  144. // Equality
  145. if (cond.eq !== undefined) {
  146. const rhs = resolveComparisonValue(cond.eq, ctx);
  147. result = value === rhs;
  148. }
  149. // Inequality
  150. else if (cond.neq !== undefined) {
  151. const rhs = resolveComparisonValue(cond.neq, ctx);
  152. result = value !== rhs;
  153. }
  154. // Greater than
  155. else if (cond.gt !== undefined) {
  156. const rhs = resolveComparisonValue(cond.gt, ctx);
  157. result =
  158. typeof value === "number" && typeof rhs === "number"
  159. ? value > rhs
  160. : false;
  161. }
  162. // Greater than or equal
  163. else if (cond.gte !== undefined) {
  164. const rhs = resolveComparisonValue(cond.gte, ctx);
  165. result =
  166. typeof value === "number" && typeof rhs === "number"
  167. ? value >= rhs
  168. : false;
  169. }
  170. // Less than
  171. else if (cond.lt !== undefined) {
  172. const rhs = resolveComparisonValue(cond.lt, ctx);
  173. result =
  174. typeof value === "number" && typeof rhs === "number"
  175. ? value < rhs
  176. : false;
  177. }
  178. // Less than or equal
  179. else if (cond.lte !== undefined) {
  180. const rhs = resolveComparisonValue(cond.lte, ctx);
  181. result =
  182. typeof value === "number" && typeof rhs === "number"
  183. ? value <= rhs
  184. : false;
  185. }
  186. // Truthiness (no operator)
  187. else {
  188. result = Boolean(value);
  189. }
  190. // `not` inverts the result of any condition
  191. return cond.not === true ? !result : result;
  192. }
  193. /**
  194. * Type guard for AndCondition
  195. */
  196. function isAndCondition(
  197. condition: VisibilityCondition,
  198. ): condition is AndCondition {
  199. return (
  200. typeof condition === "object" &&
  201. condition !== null &&
  202. !Array.isArray(condition) &&
  203. "$and" in condition
  204. );
  205. }
  206. /**
  207. * Type guard for OrCondition
  208. */
  209. function isOrCondition(
  210. condition: VisibilityCondition,
  211. ): condition is OrCondition {
  212. return (
  213. typeof condition === "object" &&
  214. condition !== null &&
  215. !Array.isArray(condition) &&
  216. "$or" in condition
  217. );
  218. }
  219. /**
  220. * Evaluate a visibility condition.
  221. *
  222. * - `undefined` → visible
  223. * - `boolean` → that value
  224. * - `SingleCondition` → evaluate single condition
  225. * - `SingleCondition[]` → implicit AND (all must be true)
  226. * - `AndCondition` → `{ $and: [...] }`, explicit AND
  227. * - `OrCondition` → `{ $or: [...] }`, at least one must be true
  228. */
  229. export function evaluateVisibility(
  230. condition: VisibilityCondition | undefined,
  231. ctx: VisibilityContext,
  232. ): boolean {
  233. // No condition = visible
  234. if (condition === undefined) {
  235. return true;
  236. }
  237. // Boolean literal
  238. if (typeof condition === "boolean") {
  239. return condition;
  240. }
  241. // Array = implicit AND
  242. if (Array.isArray(condition)) {
  243. return condition.every((c) => evaluateCondition(c, ctx));
  244. }
  245. // Explicit AND condition
  246. if (isAndCondition(condition)) {
  247. return condition.$and.every((child) => evaluateVisibility(child, ctx));
  248. }
  249. // OR condition
  250. if (isOrCondition(condition)) {
  251. return condition.$or.some((child) => evaluateVisibility(child, ctx));
  252. }
  253. // Single condition
  254. return evaluateCondition(condition, ctx);
  255. }
  256. // =============================================================================
  257. // Helpers
  258. // =============================================================================
  259. /**
  260. * Helper to create visibility conditions.
  261. */
  262. export const visibility = {
  263. /** Always visible */
  264. always: true as const,
  265. /** Never visible */
  266. never: false as const,
  267. /** Visible when state path is truthy */
  268. when: (path: string): StateCondition => ({ $state: path }),
  269. /** Visible when state path is falsy */
  270. unless: (path: string): StateCondition => ({ $state: path, not: true }),
  271. /** Equality check */
  272. eq: (path: string, value: unknown): StateCondition => ({
  273. $state: path,
  274. eq: value,
  275. }),
  276. /** Not equal check */
  277. neq: (path: string, value: unknown): StateCondition => ({
  278. $state: path,
  279. neq: value,
  280. }),
  281. /** Greater than */
  282. gt: (path: string, value: number | { $state: string }): StateCondition => ({
  283. $state: path,
  284. gt: value,
  285. }),
  286. /** Greater than or equal */
  287. gte: (path: string, value: number | { $state: string }): StateCondition => ({
  288. $state: path,
  289. gte: value,
  290. }),
  291. /** Less than */
  292. lt: (path: string, value: number | { $state: string }): StateCondition => ({
  293. $state: path,
  294. lt: value,
  295. }),
  296. /** Less than or equal */
  297. lte: (path: string, value: number | { $state: string }): StateCondition => ({
  298. $state: path,
  299. lte: value,
  300. }),
  301. /** AND multiple conditions */
  302. and: (...conditions: VisibilityCondition[]): AndCondition => ({
  303. $and: conditions,
  304. }),
  305. /** OR multiple conditions */
  306. or: (...conditions: VisibilityCondition[]): OrCondition => ({
  307. $or: conditions,
  308. }),
  309. };