visibility.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431
  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. const StrictSingleConditionSchema = z.union([
  65. z.strictObject({ $state: z.string(), ...comparisonOps }),
  66. z.strictObject({ $item: z.string(), ...comparisonOps }),
  67. z.strictObject({ $index: z.literal(true), ...comparisonOps }),
  68. ]);
  69. /**
  70. * Strict variant for spec validation: rejects unknown keys, so malformed
  71. * conditions (e.g. mixing $state and $item in one object) are caught at
  72. * validation time instead of silently evaluating to hidden at runtime.
  73. */
  74. /**
  75. * True when a condition references the repeat-item scope ($item or $index)
  76. * anywhere in its tree. Renderers use this to apply a repeat container's own
  77. * visible condition as a per-item filter instead of evaluating it (and
  78. * failing) outside the repeat scope.
  79. */
  80. export function conditionUsesItemScope(
  81. condition: VisibilityCondition | undefined,
  82. ): boolean {
  83. if (condition === undefined || typeof condition === "boolean") return false;
  84. if (Array.isArray(condition)) return condition.some(conditionUsesItemScope);
  85. if (typeof condition !== "object" || condition === null) return false;
  86. if ("$item" in condition || "$index" in condition) return true;
  87. if ("$and" in condition)
  88. return (condition as { $and: VisibilityCondition[] }).$and.some(
  89. conditionUsesItemScope,
  90. );
  91. if ("$or" in condition)
  92. return (condition as { $or: VisibilityCondition[] }).$or.some(
  93. conditionUsesItemScope,
  94. );
  95. return false;
  96. }
  97. /**
  98. * Splits a repeat container's visible condition into a container-level gate
  99. * and a per-item filter. Top-level AND structures (arrays, $and) partition
  100. * cleanly: conjuncts that reference $item/$index filter items, the rest gate
  101. * the container. An $or that mixes scopes cannot be partitioned soundly and
  102. * is applied entirely per item (state parts still evaluate correctly there;
  103. * the container shell just cannot be hidden by it).
  104. */
  105. export function splitRepeatVisibility(
  106. condition: VisibilityCondition | undefined,
  107. ): {
  108. container: VisibilityCondition | undefined;
  109. itemFilter: VisibilityCondition | undefined;
  110. } {
  111. if (condition === undefined || !conditionUsesItemScope(condition)) {
  112. return { container: condition, itemFilter: undefined };
  113. }
  114. const partition = (parts: VisibilityCondition[]) => {
  115. const container = parts.filter((part) => !conditionUsesItemScope(part));
  116. const item = parts.filter((part) => conditionUsesItemScope(part));
  117. return {
  118. container: container.length > 0 ? { $and: container } : undefined,
  119. itemFilter: item.length > 0 ? { $and: item } : undefined,
  120. };
  121. };
  122. if (Array.isArray(condition)) return partition(condition);
  123. if (
  124. typeof condition === "object" &&
  125. condition !== null &&
  126. "$and" in condition
  127. ) {
  128. return partition((condition as { $and: VisibilityCondition[] }).$and);
  129. }
  130. // Single item-scoped condition or an $or that mixes scopes.
  131. return { container: undefined, itemFilter: condition };
  132. }
  133. export const VisibilityConditionStrictSchema: z.ZodType<VisibilityCondition> =
  134. z.lazy(() =>
  135. z.union([
  136. z.boolean(),
  137. StrictSingleConditionSchema,
  138. z.array(StrictSingleConditionSchema),
  139. z.strictObject({ $and: z.array(VisibilityConditionStrictSchema) }),
  140. z.strictObject({ $or: z.array(VisibilityConditionStrictSchema) }),
  141. ]),
  142. );
  143. // =============================================================================
  144. // Context
  145. // =============================================================================
  146. /**
  147. * Context for evaluating visibility conditions.
  148. *
  149. * `repeatItem` and `repeatIndex` are only present inside a `repeat` scope
  150. * and enable `$item` / `$index` conditions.
  151. */
  152. export interface VisibilityContext {
  153. stateModel: StateModel;
  154. /** The current repeat item (set inside a repeat scope). */
  155. repeatItem?: unknown;
  156. /** The current repeat array index (set inside a repeat scope). */
  157. repeatIndex?: number;
  158. }
  159. // =============================================================================
  160. // Evaluation
  161. // =============================================================================
  162. /**
  163. * Resolve a comparison value. If it's a `{ $state }` reference, look it up;
  164. * otherwise return the literal.
  165. */
  166. function resolveComparisonValue(
  167. value: unknown,
  168. ctx: VisibilityContext,
  169. ): unknown {
  170. if (typeof value === "object" && value !== null) {
  171. if (
  172. "$state" in value &&
  173. typeof (value as Record<string, unknown>).$state === "string"
  174. ) {
  175. return getByPath(ctx.stateModel, (value as { $state: string }).$state);
  176. }
  177. }
  178. return value;
  179. }
  180. /**
  181. * Type guards for condition sources.
  182. */
  183. function isItemCondition(cond: SingleCondition): cond is ItemCondition {
  184. return "$item" in cond;
  185. }
  186. function isIndexCondition(cond: SingleCondition): cond is IndexCondition {
  187. return "$index" in cond;
  188. }
  189. /**
  190. * Resolve the left-hand-side value of a condition based on its source.
  191. */
  192. function resolveConditionValue(
  193. cond: SingleCondition,
  194. ctx: VisibilityContext,
  195. ): unknown {
  196. if (isIndexCondition(cond)) {
  197. return ctx.repeatIndex;
  198. }
  199. if (isItemCondition(cond)) {
  200. if (ctx.repeatItem === undefined) return undefined;
  201. return cond.$item === ""
  202. ? ctx.repeatItem
  203. : getByPath(ctx.repeatItem, cond.$item);
  204. }
  205. // StateCondition
  206. return getByPath(ctx.stateModel, (cond as StateCondition).$state);
  207. }
  208. /**
  209. * Evaluate a single condition against the context.
  210. *
  211. * When `not` is `true`, the final result is inverted — this applies to
  212. * whichever operator is present (or to the truthiness check if no operator
  213. * is given). For example:
  214. * - `{ $state: "/x", not: true }` → `!Boolean(value)`
  215. * - `{ $state: "/x", gt: 5, not: true }` → `!(value > 5)`
  216. */
  217. function evaluateCondition(
  218. cond: SingleCondition,
  219. ctx: VisibilityContext,
  220. ): boolean {
  221. const value = resolveConditionValue(cond, ctx);
  222. let result: boolean;
  223. // Equality
  224. if (cond.eq !== undefined) {
  225. const rhs = resolveComparisonValue(cond.eq, ctx);
  226. result = value === rhs;
  227. }
  228. // Inequality
  229. else if (cond.neq !== undefined) {
  230. const rhs = resolveComparisonValue(cond.neq, ctx);
  231. result = value !== rhs;
  232. }
  233. // Greater than
  234. else if (cond.gt !== undefined) {
  235. const rhs = resolveComparisonValue(cond.gt, ctx);
  236. result =
  237. typeof value === "number" && typeof rhs === "number"
  238. ? value > rhs
  239. : false;
  240. }
  241. // Greater than or equal
  242. else if (cond.gte !== undefined) {
  243. const rhs = resolveComparisonValue(cond.gte, ctx);
  244. result =
  245. typeof value === "number" && typeof rhs === "number"
  246. ? value >= rhs
  247. : false;
  248. }
  249. // Less than
  250. else if (cond.lt !== undefined) {
  251. const rhs = resolveComparisonValue(cond.lt, ctx);
  252. result =
  253. typeof value === "number" && typeof rhs === "number"
  254. ? value < rhs
  255. : false;
  256. }
  257. // Less than or equal
  258. else if (cond.lte !== undefined) {
  259. const rhs = resolveComparisonValue(cond.lte, ctx);
  260. result =
  261. typeof value === "number" && typeof rhs === "number"
  262. ? value <= rhs
  263. : false;
  264. }
  265. // Truthiness (no operator)
  266. else {
  267. result = Boolean(value);
  268. }
  269. // `not` inverts the result of any condition
  270. return cond.not === true ? !result : result;
  271. }
  272. /**
  273. * Type guard for AndCondition
  274. */
  275. function isAndCondition(
  276. condition: VisibilityCondition,
  277. ): condition is AndCondition {
  278. return (
  279. typeof condition === "object" &&
  280. condition !== null &&
  281. !Array.isArray(condition) &&
  282. "$and" in condition
  283. );
  284. }
  285. /**
  286. * Type guard for OrCondition
  287. */
  288. function isOrCondition(
  289. condition: VisibilityCondition,
  290. ): condition is OrCondition {
  291. return (
  292. typeof condition === "object" &&
  293. condition !== null &&
  294. !Array.isArray(condition) &&
  295. "$or" in condition
  296. );
  297. }
  298. /**
  299. * Evaluate a visibility condition.
  300. *
  301. * - `undefined` → visible
  302. * - `boolean` → that value
  303. * - `SingleCondition` → evaluate single condition
  304. * - `SingleCondition[]` → implicit AND (all must be true)
  305. * - `AndCondition` → `{ $and: [...] }`, explicit AND
  306. * - `OrCondition` → `{ $or: [...] }`, at least one must be true
  307. */
  308. export function evaluateVisibility(
  309. condition: VisibilityCondition | undefined,
  310. ctx: VisibilityContext,
  311. ): boolean {
  312. // No condition = visible
  313. if (condition === undefined) {
  314. return true;
  315. }
  316. // Boolean literal
  317. if (typeof condition === "boolean") {
  318. return condition;
  319. }
  320. // Array = implicit AND
  321. if (Array.isArray(condition)) {
  322. return condition.every((c) => evaluateCondition(c, ctx));
  323. }
  324. // Explicit AND condition
  325. if (isAndCondition(condition)) {
  326. return condition.$and.every((child) => evaluateVisibility(child, ctx));
  327. }
  328. // OR condition
  329. if (isOrCondition(condition)) {
  330. return condition.$or.some((child) => evaluateVisibility(child, ctx));
  331. }
  332. // Single condition
  333. return evaluateCondition(condition, ctx);
  334. }
  335. // =============================================================================
  336. // Helpers
  337. // =============================================================================
  338. /**
  339. * Helper to create visibility conditions.
  340. */
  341. export const visibility = {
  342. /** Always visible */
  343. always: true as const,
  344. /** Never visible */
  345. never: false as const,
  346. /** Visible when state path is truthy */
  347. when: (path: string): StateCondition => ({ $state: path }),
  348. /** Visible when state path is falsy */
  349. unless: (path: string): StateCondition => ({ $state: path, not: true }),
  350. /** Equality check */
  351. eq: (path: string, value: unknown): StateCondition => ({
  352. $state: path,
  353. eq: value,
  354. }),
  355. /** Not equal check */
  356. neq: (path: string, value: unknown): StateCondition => ({
  357. $state: path,
  358. neq: value,
  359. }),
  360. /** Greater than */
  361. gt: (path: string, value: number | { $state: string }): StateCondition => ({
  362. $state: path,
  363. gt: value,
  364. }),
  365. /** Greater than or equal */
  366. gte: (path: string, value: number | { $state: string }): StateCondition => ({
  367. $state: path,
  368. gte: value,
  369. }),
  370. /** Less than */
  371. lt: (path: string, value: number | { $state: string }): StateCondition => ({
  372. $state: path,
  373. lt: value,
  374. }),
  375. /** Less than or equal */
  376. lte: (path: string, value: number | { $state: string }): StateCondition => ({
  377. $state: path,
  378. lte: value,
  379. }),
  380. /** AND multiple conditions */
  381. and: (...conditions: VisibilityCondition[]): AndCondition => ({
  382. $and: conditions,
  383. }),
  384. /** OR multiple conditions */
  385. or: (...conditions: VisibilityCondition[]): OrCondition => ({
  386. $or: conditions,
  387. }),
  388. };