@aiaiai-pt/design-system 0.44.1 → 0.45.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/ActionFormRenderer.svelte +51 -9
- package/components/ActionFormRenderer.svelte.d.ts +4 -0
- package/components/LayoutStackedDefault.svelte +36 -3
- package/components/LayoutStackedDefault.svelte.d.ts +1 -1
- package/components/action-form-renderer-layouts.d.ts +17 -0
- package/components/action-form-renderer-layouts.ts +37 -3
- package/components/action-form-visibility.d.ts +48 -0
- package/components/action-form-visibility.ts +171 -0
- package/components/index.d.ts +1 -0
- package/components/index.js +8 -0
- package/package.json +5 -1
|
@@ -24,7 +24,11 @@
|
|
|
24
24
|
// `target_config.layout_key` and validated through `resolveLayout`,
|
|
25
25
|
// so an operator-supplied string never reaches the DOM verbatim
|
|
26
26
|
// (RH#11 / R-SEC-07 / TH-08).
|
|
27
|
-
import { resolveLayout } from "./action-form-renderer-layouts";
|
|
27
|
+
import { resolveLayout, type LayoutSection } from "./action-form-renderer-layouts";
|
|
28
|
+
// #634 S4 — live section show/hide from the contract's `visible_when`
|
|
29
|
+
// predicate (pure client evaluation over the value bag; the server owns
|
|
30
|
+
// enforcement).
|
|
31
|
+
import { sectionVisible } from "./action-form-visibility";
|
|
28
32
|
|
|
29
33
|
/**
|
|
30
34
|
* The renderer treats every parameter/action/placement as a loose record
|
|
@@ -42,7 +46,15 @@
|
|
|
42
46
|
placement?: Entity | null;
|
|
43
47
|
target?: Record<string, unknown> | null;
|
|
44
48
|
source?: Record<string, unknown> | null;
|
|
45
|
-
sections?: Array<{
|
|
49
|
+
sections?: Array<{
|
|
50
|
+
key?: string;
|
|
51
|
+
label?: string;
|
|
52
|
+
order?: number | null;
|
|
53
|
+
columns?: number | null;
|
|
54
|
+
/** Read defensively (hosts carry concrete precondition types). */
|
|
55
|
+
visible_when?: unknown;
|
|
56
|
+
parameters?: Entity[];
|
|
57
|
+
}>;
|
|
46
58
|
parameters?: Entity[];
|
|
47
59
|
criteria?: Entity[];
|
|
48
60
|
};
|
|
@@ -193,12 +205,21 @@
|
|
|
193
205
|
);
|
|
194
206
|
const schemaSections = $derived(schemaSectionsFromContract());
|
|
195
207
|
const sections = $derived(schemaSections ?? groupIntoSections(visibleParameters));
|
|
208
|
+
// #634 S4 — LIVE conditional sections: a section whose `visible_when`
|
|
209
|
+
// predicate fails against the current value bag is hidden (and re-appears
|
|
210
|
+
// the moment the driving value changes). Sections without a predicate —
|
|
211
|
+
// including every legacy fold section — always pass, so this is inert
|
|
212
|
+
// until a sheet declares `visible_when`. The value bag still spans ALL
|
|
213
|
+
// parameters (hidden sections keep their answers).
|
|
214
|
+
const liveSections = $derived(
|
|
215
|
+
sections.filter((section) => sectionVisible(section, values)),
|
|
216
|
+
);
|
|
196
217
|
// Wizard pagination (#105 P6): when a section is active, render only it. The
|
|
197
218
|
// value bag is unaffected — every parameter is still seeded and submitted.
|
|
198
219
|
const displaySections = $derived(
|
|
199
220
|
activeSectionKey == null
|
|
200
|
-
?
|
|
201
|
-
:
|
|
221
|
+
? liveSections
|
|
222
|
+
: liveSections.filter((section) => section.name === activeSectionKey),
|
|
202
223
|
);
|
|
203
224
|
const payload = $derived(buildPayload());
|
|
204
225
|
const payloadJson = $derived(JSON.stringify(payload, null, 2));
|
|
@@ -216,8 +237,13 @@
|
|
|
216
237
|
function isEmpty(value: unknown): boolean {
|
|
217
238
|
return value === undefined || value === null || value === "";
|
|
218
239
|
}
|
|
240
|
+
// #634 S4 — the gate counts only parameters whose section is LIVE-visible:
|
|
241
|
+
// a required field inside a `visible_when`-hidden section must not block
|
|
242
|
+
// submit (it is unreachable). In the legacy fold path this flattens back
|
|
243
|
+
// to exactly `visibleParameters`.
|
|
244
|
+
const gateParameters = $derived(liveSections.flatMap((section) => section.items));
|
|
219
245
|
const canSubmit = $derived(
|
|
220
|
-
|
|
246
|
+
gateParameters
|
|
221
247
|
.filter((parameter) => parameter.required)
|
|
222
248
|
.every((parameter) =>
|
|
223
249
|
String(parameter.type ?? "") === "file"
|
|
@@ -306,18 +332,34 @@
|
|
|
306
332
|
return [...byName.entries()].map(([name, sectionItems]) => ({ name, items: sectionItems }));
|
|
307
333
|
}
|
|
308
334
|
|
|
309
|
-
function schemaSectionsFromContract():
|
|
335
|
+
function schemaSectionsFromContract(): LayoutSection[] | null {
|
|
310
336
|
if (!Array.isArray(schema?.sections)) return null;
|
|
311
337
|
const next = schema.sections
|
|
312
|
-
.map((section) => {
|
|
338
|
+
.map((section, index) => {
|
|
313
339
|
const items = Array.isArray(section.parameters)
|
|
314
340
|
? section.parameters
|
|
315
341
|
.filter((parameter) => parameter.is_active !== false && isVisible(parameter))
|
|
316
342
|
.sort((a, b) => Number(a.order ?? 0) - Number(b.order ?? 0))
|
|
317
343
|
: [];
|
|
318
|
-
|
|
344
|
+
// #634 S3/S4 — carry the contract's presentation keys through to the
|
|
345
|
+
// layouts: stable `key`, declared `columns`, the `visible_when`
|
|
346
|
+
// predicate, and `order` (the server emits sections pre-sorted; the
|
|
347
|
+
// sort below is a defensive mirror, stable via declaration index).
|
|
348
|
+
return {
|
|
349
|
+
name: String(section.label ?? section.key ?? "Details"),
|
|
350
|
+
key: section.key ? String(section.key) : undefined,
|
|
351
|
+
columns: Number(section.columns ?? 1),
|
|
352
|
+
visibleWhen:
|
|
353
|
+
section.visible_when && typeof section.visible_when === "object"
|
|
354
|
+
? (section.visible_when as Record<string, unknown>)
|
|
355
|
+
: null,
|
|
356
|
+
order: Number(section.order ?? 0),
|
|
357
|
+
index,
|
|
358
|
+
items,
|
|
359
|
+
};
|
|
319
360
|
})
|
|
320
|
-
.filter((section) => section.items.length)
|
|
361
|
+
.filter((section) => section.items.length)
|
|
362
|
+
.sort((a, b) => a.order - b.order || a.index - b.index);
|
|
321
363
|
return next.length ? next : null;
|
|
322
364
|
}
|
|
323
365
|
|
|
@@ -22,6 +22,10 @@ type ActionSchema = {
|
|
|
22
22
|
sections?: Array<{
|
|
23
23
|
key?: string;
|
|
24
24
|
label?: string;
|
|
25
|
+
order?: number | null;
|
|
26
|
+
columns?: number | null;
|
|
27
|
+
/** Read defensively (hosts carry concrete precondition types). */
|
|
28
|
+
visible_when?: unknown;
|
|
25
29
|
parameters?: Entity[];
|
|
26
30
|
}>;
|
|
27
31
|
parameters?: Entity[];
|
|
@@ -5,9 +5,15 @@
|
|
|
5
5
|
* Each section is a labelled <fieldset>, parameters laid out as a
|
|
6
6
|
* single-column flex column. This is the safe fallback for unknown
|
|
7
7
|
* layout keys — see `resolve.ts`.
|
|
8
|
+
*
|
|
9
|
+
* #634 S3 — a section declaring `columns: 2` lays its cells on a
|
|
10
|
+
* two-column grid; `span: full` params (and wide types — geo/file)
|
|
11
|
+
* break out to the full row (the EntityFormSurface two-col precedent).
|
|
12
|
+
* Default (columns 1 / absent) keeps the single-column stack
|
|
13
|
+
* byte-identical.
|
|
8
14
|
*/
|
|
9
15
|
import Card from "./Card.svelte";
|
|
10
|
-
import type
|
|
16
|
+
import { fieldSpansFull, type LayoutSection } from "./action-form-renderer-layouts";
|
|
11
17
|
import type { Snippet } from "svelte";
|
|
12
18
|
|
|
13
19
|
let {
|
|
@@ -23,9 +29,16 @@
|
|
|
23
29
|
{#each sections as section (section.name)}
|
|
24
30
|
<Card variant="flat" class="section" role="group" aria-label={section.name}>
|
|
25
31
|
<h4>{section.name}</h4>
|
|
26
|
-
<div
|
|
32
|
+
<div
|
|
33
|
+
class="rows"
|
|
34
|
+
class:two-col={(section.columns ?? 1) === 2}
|
|
35
|
+
data-columns={section.columns ?? 1}
|
|
36
|
+
>
|
|
27
37
|
{#each section.items as parameter}
|
|
28
|
-
<div
|
|
38
|
+
<div
|
|
39
|
+
class="field-row"
|
|
40
|
+
class:span-full={fieldSpansFull(parameter, section.columns)}
|
|
41
|
+
>
|
|
29
42
|
{@render field(parameter)}
|
|
30
43
|
</div>
|
|
31
44
|
{/each}
|
|
@@ -53,6 +66,26 @@
|
|
|
53
66
|
gap: var(--space-md);
|
|
54
67
|
}
|
|
55
68
|
|
|
69
|
+
/* #634 S3 — two-column density: half-width cells flow the grid,
|
|
70
|
+
span-full cells break out to the whole row. Collapses back to one
|
|
71
|
+
column on narrow viewports (the EntityFormSurface breakpoint). */
|
|
72
|
+
.rows.two-col {
|
|
73
|
+
display: grid;
|
|
74
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
75
|
+
column-gap: var(--space-md);
|
|
76
|
+
row-gap: var(--space-md);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.rows.two-col .field-row.span-full {
|
|
80
|
+
grid-column: 1 / -1;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
@media (max-width: 48rem) {
|
|
84
|
+
.rows.two-col {
|
|
85
|
+
grid-template-columns: 1fr;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
56
89
|
.field-row {
|
|
57
90
|
display: flex;
|
|
58
91
|
flex-direction: column;
|
|
@@ -34,7 +34,24 @@ export interface LayoutEntry {
|
|
|
34
34
|
export interface LayoutSection {
|
|
35
35
|
name: string;
|
|
36
36
|
items: Array<Record<string, unknown>>;
|
|
37
|
+
/** #634 S3 — stable contract key (schema.sections[].key), when present. */
|
|
38
|
+
key?: string;
|
|
39
|
+
/** #634 S3 — declared section density (1|2). Layouts that arrange rows
|
|
40
|
+
* vertically render a two-column grid when 2; other layouts may ignore
|
|
41
|
+
* it (their arrangement is their own contract). Default 1. */
|
|
42
|
+
columns?: number;
|
|
43
|
+
/** #634 S4 — the section's optional `visible_when` predicate, carried
|
|
44
|
+
* from the contract for live show/hide (see action-form-visibility). */
|
|
45
|
+
visibleWhen?: Record<string, unknown> | null;
|
|
37
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* #634 S3 — whether a parameter's cell spans the full row inside a
|
|
49
|
+
* `columns: 2` section. An explicit `span: "full"` on the parameter wins;
|
|
50
|
+
* wide types clamp to full regardless; everything else flows half-width
|
|
51
|
+
* (so a bare `columns: 2` declaration visibly two-columns its fields).
|
|
52
|
+
* In a single-column section every cell is trivially full.
|
|
53
|
+
*/
|
|
54
|
+
export declare function fieldSpansFull(parameter: Record<string, unknown>, columns: number | undefined): boolean;
|
|
38
55
|
import type { Snippet } from "svelte";
|
|
39
56
|
export interface LayoutComponentProps {
|
|
40
57
|
sections: LayoutSection[];
|
|
@@ -41,6 +41,36 @@ export interface LayoutEntry {
|
|
|
41
41
|
export interface LayoutSection {
|
|
42
42
|
name: string;
|
|
43
43
|
items: Array<Record<string, unknown>>;
|
|
44
|
+
/** #634 S3 — stable contract key (schema.sections[].key), when present. */
|
|
45
|
+
key?: string;
|
|
46
|
+
/** #634 S3 — declared section density (1|2). Layouts that arrange rows
|
|
47
|
+
* vertically render a two-column grid when 2; other layouts may ignore
|
|
48
|
+
* it (their arrangement is their own contract). Default 1. */
|
|
49
|
+
columns?: number;
|
|
50
|
+
/** #634 S4 — the section's optional `visible_when` predicate, carried
|
|
51
|
+
* from the contract for live show/hide (see action-form-visibility). */
|
|
52
|
+
visibleWhen?: Record<string, unknown> | null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Parameter types that can never sit half-width in a two-column section —
|
|
56
|
+
* they carry their own wide UI (map canvas, upload list). Mirrors the CRUD
|
|
57
|
+
* form-surface compiler's full-width clamp (form-surface.ts). */
|
|
58
|
+
const FULL_WIDTH_PARAM_TYPES = new Set(["geo", "file", "json"]);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* #634 S3 — whether a parameter's cell spans the full row inside a
|
|
62
|
+
* `columns: 2` section. An explicit `span: "full"` on the parameter wins;
|
|
63
|
+
* wide types clamp to full regardless; everything else flows half-width
|
|
64
|
+
* (so a bare `columns: 2` declaration visibly two-columns its fields).
|
|
65
|
+
* In a single-column section every cell is trivially full.
|
|
66
|
+
*/
|
|
67
|
+
export function fieldSpansFull(
|
|
68
|
+
parameter: Record<string, unknown>,
|
|
69
|
+
columns: number | undefined,
|
|
70
|
+
): boolean {
|
|
71
|
+
if ((columns ?? 1) < 2) return true;
|
|
72
|
+
if (parameter.span === "full") return true;
|
|
73
|
+
return FULL_WIDTH_PARAM_TYPES.has(String(parameter.type ?? ""));
|
|
44
74
|
}
|
|
45
75
|
|
|
46
76
|
import type { Snippet } from "svelte";
|
|
@@ -56,9 +86,11 @@ export interface LayoutComponentProps {
|
|
|
56
86
|
/** The registry. Order is the order shown to operators in the
|
|
57
87
|
* AddPlacementPanel select; the first entry is the default. */
|
|
58
88
|
const REGISTRY: Record<LayoutKey, Component<LayoutComponentProps>> = {
|
|
59
|
-
"stacked-default":
|
|
89
|
+
"stacked-default":
|
|
90
|
+
LayoutStackedDefault as unknown as Component<LayoutComponentProps>,
|
|
60
91
|
"inline-row": LayoutInlineRow as unknown as Component<LayoutComponentProps>,
|
|
61
|
-
"compact-mobile":
|
|
92
|
+
"compact-mobile":
|
|
93
|
+
LayoutCompactMobile as unknown as Component<LayoutComponentProps>,
|
|
62
94
|
};
|
|
63
95
|
|
|
64
96
|
const DEFAULT_KEY: LayoutKey = "stacked-default";
|
|
@@ -79,4 +111,6 @@ export function resolveLayout(key: unknown): LayoutEntry {
|
|
|
79
111
|
}
|
|
80
112
|
|
|
81
113
|
/** All known keys, in registry order. Useful for picker UIs. */
|
|
82
|
-
export const LAYOUT_KEYS: readonly LayoutKey[] = Object.keys(
|
|
114
|
+
export const LAYOUT_KEYS: readonly LayoutKey[] = Object.keys(
|
|
115
|
+
REGISTRY,
|
|
116
|
+
) as LayoutKey[];
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side `visible_when` evaluation for action-form sections (#634 S4).
|
|
3
|
+
*
|
|
4
|
+
* The action's server-compiled contract round-trips an OPTIONAL
|
|
5
|
+
* `visible_when` predicate per section (the platform's Foundry-framed
|
|
6
|
+
* ActionPrecondition shape — conditions are data, one vocabulary). The
|
|
7
|
+
* server owns ENFORCEMENT (submission gates); this module owns the pure
|
|
8
|
+
* PRESENTATION concern: live show/hide of a section as the operator types.
|
|
9
|
+
*
|
|
10
|
+
* The operator set is a deliberate SUBSET of the server evaluator
|
|
11
|
+
* (`validation.py::_evaluate_operator`) — the comparisons a form value bag
|
|
12
|
+
* can answer locally. Two deliberate divergences, both UI-safe:
|
|
13
|
+
*
|
|
14
|
+
* - Blank form values (`""`, null, undefined) are treated as ABSENT for
|
|
15
|
+
* `is_null` / `not_null` — an untouched text input means "no value yet",
|
|
16
|
+
* so `{field: X, operator: not_null}` reads "shown once X has a value"
|
|
17
|
+
* (the spec's canonical example).
|
|
18
|
+
* - An operator this subset doesn't know evaluates to VISIBLE (fail-open).
|
|
19
|
+
* Hiding inputs on an unknown operator would make fields unreachable;
|
|
20
|
+
* the server's gates still enforce whatever the condition meant.
|
|
21
|
+
*/
|
|
22
|
+
export interface VisibleWhenCondition {
|
|
23
|
+
field?: string;
|
|
24
|
+
operator?: string;
|
|
25
|
+
value?: unknown;
|
|
26
|
+
[key: string]: unknown;
|
|
27
|
+
}
|
|
28
|
+
/** Blank = the form has no value for the field yet. `false` and `0` are
|
|
29
|
+
* real values; only null / undefined / empty-string count as blank. */
|
|
30
|
+
export declare function isBlankFormValue(value: unknown): boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Evaluate one `visible_when` condition against the live form value bag.
|
|
33
|
+
* Returns whether the section should be VISIBLE.
|
|
34
|
+
*/
|
|
35
|
+
export declare function evaluateVisibleWhen(condition: VisibleWhenCondition | null | undefined, values: Record<string, unknown>): boolean;
|
|
36
|
+
/** A section shape carrying an optional predicate. Reads BOTH spellings so
|
|
37
|
+
* hosts can pass either the renderer's mapped sections (`visibleWhen`) or
|
|
38
|
+
* raw contract sections (`visible_when`) without an adapter. `unknown` on
|
|
39
|
+
* purpose: hosts carry their own concrete precondition types; the evaluator
|
|
40
|
+
* reads the predicate defensively. */
|
|
41
|
+
export interface ConditionalSection {
|
|
42
|
+
visibleWhen?: unknown;
|
|
43
|
+
visible_when?: unknown;
|
|
44
|
+
}
|
|
45
|
+
/** Whether a section is visible given the live value bag. Sections with no
|
|
46
|
+
* predicate are always visible. Accepts any object shape (fold sections
|
|
47
|
+
* carry no predicate at all) — the predicate is read defensively. */
|
|
48
|
+
export declare function sectionVisible(section: object | null | undefined, values: Record<string, unknown>): boolean;
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side `visible_when` evaluation for action-form sections (#634 S4).
|
|
3
|
+
*
|
|
4
|
+
* The action's server-compiled contract round-trips an OPTIONAL
|
|
5
|
+
* `visible_when` predicate per section (the platform's Foundry-framed
|
|
6
|
+
* ActionPrecondition shape — conditions are data, one vocabulary). The
|
|
7
|
+
* server owns ENFORCEMENT (submission gates); this module owns the pure
|
|
8
|
+
* PRESENTATION concern: live show/hide of a section as the operator types.
|
|
9
|
+
*
|
|
10
|
+
* The operator set is a deliberate SUBSET of the server evaluator
|
|
11
|
+
* (`validation.py::_evaluate_operator`) — the comparisons a form value bag
|
|
12
|
+
* can answer locally. Two deliberate divergences, both UI-safe:
|
|
13
|
+
*
|
|
14
|
+
* - Blank form values (`""`, null, undefined) are treated as ABSENT for
|
|
15
|
+
* `is_null` / `not_null` — an untouched text input means "no value yet",
|
|
16
|
+
* so `{field: X, operator: not_null}` reads "shown once X has a value"
|
|
17
|
+
* (the spec's canonical example).
|
|
18
|
+
* - An operator this subset doesn't know evaluates to VISIBLE (fail-open).
|
|
19
|
+
* Hiding inputs on an unknown operator would make fields unreachable;
|
|
20
|
+
* the server's gates still enforce whatever the condition meant.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export interface VisibleWhenCondition {
|
|
24
|
+
field?: string;
|
|
25
|
+
operator?: string;
|
|
26
|
+
value?: unknown;
|
|
27
|
+
[key: string]: unknown;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Blank = the form has no value for the field yet. `false` and `0` are
|
|
31
|
+
* real values; only null / undefined / empty-string count as blank. */
|
|
32
|
+
export function isBlankFormValue(value: unknown): boolean {
|
|
33
|
+
return value === undefined || value === null || value === "";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function asComparableNumber(value: unknown): number | null {
|
|
37
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : null;
|
|
38
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
39
|
+
const parsed = Number(value);
|
|
40
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function numericCompare(
|
|
46
|
+
operator: "gt" | "lt" | "gte" | "lte",
|
|
47
|
+
actual: unknown,
|
|
48
|
+
expected: unknown,
|
|
49
|
+
): boolean {
|
|
50
|
+
// Mirrors the server: a missing actual is VACUOUSLY satisfied for the
|
|
51
|
+
// ordering operators (the gate that owns refusal runs server-side).
|
|
52
|
+
if (isBlankFormValue(actual)) return true;
|
|
53
|
+
const left = asComparableNumber(actual);
|
|
54
|
+
const right = asComparableNumber(expected);
|
|
55
|
+
if (left === null || right === null) {
|
|
56
|
+
// Fall back to string comparison only when both sides are strings
|
|
57
|
+
// (e.g. ISO dates order lexicographically); otherwise fail open.
|
|
58
|
+
if (typeof actual === "string" && typeof expected === "string") {
|
|
59
|
+
if (operator === "gt") return actual > expected;
|
|
60
|
+
if (operator === "lt") return actual < expected;
|
|
61
|
+
if (operator === "gte") return actual >= expected;
|
|
62
|
+
return actual <= expected;
|
|
63
|
+
}
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
if (operator === "gt") return left > right;
|
|
67
|
+
if (operator === "lt") return left < right;
|
|
68
|
+
if (operator === "gte") return left >= right;
|
|
69
|
+
return left <= right;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function looseEquals(actual: unknown, expected: unknown): boolean {
|
|
73
|
+
if (actual === expected) return true;
|
|
74
|
+
// Form controls carry strings; sheets author typed literals. "3" == 3 and
|
|
75
|
+
// "true" == true are the same answer from the operator's point of view.
|
|
76
|
+
if (typeof expected === "boolean") return String(actual) === String(expected);
|
|
77
|
+
const leftNumber = asComparableNumber(actual);
|
|
78
|
+
const rightNumber = asComparableNumber(expected);
|
|
79
|
+
if (leftNumber !== null && rightNumber !== null)
|
|
80
|
+
return leftNumber === rightNumber;
|
|
81
|
+
return String(actual ?? "") === String(expected ?? "");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function stringMatch(
|
|
85
|
+
mode: "contains" | "starts" | "ends",
|
|
86
|
+
actual: unknown,
|
|
87
|
+
expected: unknown,
|
|
88
|
+
): boolean {
|
|
89
|
+
if (isBlankFormValue(actual) || isBlankFormValue(expected)) return false;
|
|
90
|
+
const haystack = String(actual).toLowerCase();
|
|
91
|
+
const needle = String(expected).toLowerCase();
|
|
92
|
+
if (mode === "contains") return haystack.includes(needle);
|
|
93
|
+
if (mode === "starts") return haystack.startsWith(needle);
|
|
94
|
+
return haystack.endsWith(needle);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Evaluate one `visible_when` condition against the live form value bag.
|
|
99
|
+
* Returns whether the section should be VISIBLE.
|
|
100
|
+
*/
|
|
101
|
+
export function evaluateVisibleWhen(
|
|
102
|
+
condition: VisibleWhenCondition | null | undefined,
|
|
103
|
+
values: Record<string, unknown>,
|
|
104
|
+
): boolean {
|
|
105
|
+
if (!condition || typeof condition !== "object") return true;
|
|
106
|
+
const field = typeof condition.field === "string" ? condition.field : "";
|
|
107
|
+
const operator =
|
|
108
|
+
typeof condition.operator === "string" ? condition.operator : "";
|
|
109
|
+
if (!field || !operator) return true;
|
|
110
|
+
const actual = values[field];
|
|
111
|
+
const expected = condition.value;
|
|
112
|
+
|
|
113
|
+
switch (operator) {
|
|
114
|
+
case "eq":
|
|
115
|
+
return looseEquals(actual, expected);
|
|
116
|
+
case "ne":
|
|
117
|
+
return !looseEquals(actual, expected);
|
|
118
|
+
case "gt":
|
|
119
|
+
case "lt":
|
|
120
|
+
case "gte":
|
|
121
|
+
case "lte":
|
|
122
|
+
return numericCompare(operator, actual, expected);
|
|
123
|
+
case "in":
|
|
124
|
+
return Array.isArray(expected)
|
|
125
|
+
? expected.some((entry) => looseEquals(actual, entry))
|
|
126
|
+
: false;
|
|
127
|
+
case "not_in":
|
|
128
|
+
return Array.isArray(expected)
|
|
129
|
+
? !expected.some((entry) => looseEquals(actual, entry))
|
|
130
|
+
: true;
|
|
131
|
+
case "is_null":
|
|
132
|
+
return isBlankFormValue(actual);
|
|
133
|
+
case "not_null":
|
|
134
|
+
return !isBlankFormValue(actual);
|
|
135
|
+
case "contains":
|
|
136
|
+
return stringMatch("contains", actual, expected);
|
|
137
|
+
case "starts_with":
|
|
138
|
+
return stringMatch("starts", actual, expected);
|
|
139
|
+
case "ends_with":
|
|
140
|
+
return stringMatch("ends", actual, expected);
|
|
141
|
+
default:
|
|
142
|
+
// Unknown operator → fail-open VISIBLE (see module doc).
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** A section shape carrying an optional predicate. Reads BOTH spellings so
|
|
148
|
+
* hosts can pass either the renderer's mapped sections (`visibleWhen`) or
|
|
149
|
+
* raw contract sections (`visible_when`) without an adapter. `unknown` on
|
|
150
|
+
* purpose: hosts carry their own concrete precondition types; the evaluator
|
|
151
|
+
* reads the predicate defensively. */
|
|
152
|
+
export interface ConditionalSection {
|
|
153
|
+
visibleWhen?: unknown;
|
|
154
|
+
visible_when?: unknown;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Whether a section is visible given the live value bag. Sections with no
|
|
158
|
+
* predicate are always visible. Accepts any object shape (fold sections
|
|
159
|
+
* carry no predicate at all) — the predicate is read defensively. */
|
|
160
|
+
export function sectionVisible(
|
|
161
|
+
section: object | null | undefined,
|
|
162
|
+
values: Record<string, unknown>,
|
|
163
|
+
): boolean {
|
|
164
|
+
if (!section) return true;
|
|
165
|
+
const bag = section as ConditionalSection;
|
|
166
|
+
const predicate =
|
|
167
|
+
(bag.visibleWhen as VisibleWhenCondition | null | undefined) ??
|
|
168
|
+
(bag.visible_when as VisibleWhenCondition | null | undefined) ??
|
|
169
|
+
null;
|
|
170
|
+
return evaluateVisibleWhen(predicate, values);
|
|
171
|
+
}
|
package/components/index.d.ts
CHANGED
|
@@ -106,3 +106,4 @@ export { default as GeoSearch } from "./GeoSearch.svelte";
|
|
|
106
106
|
export { default as Calendar } from "./Calendar.svelte";
|
|
107
107
|
export { default as ActionFormRenderer } from "./ActionFormRenderer.svelte";
|
|
108
108
|
export { serializeValueSource, parseValueSource } from "./ValueSourcePicker.helpers.js";
|
|
109
|
+
export { evaluateVisibleWhen, isBlankFormValue, sectionVisible } from "./action-form-visibility";
|
package/components/index.js
CHANGED
|
@@ -173,3 +173,11 @@ export { default as Calendar } from "./Calendar.svelte";
|
|
|
173
173
|
|
|
174
174
|
// Action form renderer (placement-aware; admin preview + portal runtime, #73)
|
|
175
175
|
export { default as ActionFormRenderer } from "./ActionFormRenderer.svelte";
|
|
176
|
+
// #634 S4 — pure client-side `visible_when` evaluation (section show/hide).
|
|
177
|
+
// Exported at the root so host step-models (wizard rails) share the exact
|
|
178
|
+
// evaluator the renderer applies internally.
|
|
179
|
+
export {
|
|
180
|
+
evaluateVisibleWhen,
|
|
181
|
+
isBlankFormValue,
|
|
182
|
+
sectionVisible,
|
|
183
|
+
} from "./action-form-visibility";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aiaiai-pt/design-system",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.45.0",
|
|
4
4
|
"description": "Design system tokens and Svelte components for aiaiai products",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -31,6 +31,10 @@
|
|
|
31
31
|
"svelte": "./components/index.js",
|
|
32
32
|
"default": "./components/index.js"
|
|
33
33
|
},
|
|
34
|
+
"./components/action-form-visibility": {
|
|
35
|
+
"types": "./components/action-form-visibility.d.ts",
|
|
36
|
+
"default": "./components/action-form-visibility.ts"
|
|
37
|
+
},
|
|
34
38
|
"./components/*.svelte": {
|
|
35
39
|
"types": "./components/*.svelte.d.ts",
|
|
36
40
|
"svelte": "./components/*.svelte",
|