@aiaiai-pt/design-system 0.15.0 → 0.16.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 +117 -2
- package/components/ActionFormRenderer.svelte.d.ts +9 -0
- package/components/GeoSearch.svelte +8 -2
- package/components/GeoSearch.svelte.d.ts +2 -0
- package/components/MapCluster.svelte +52 -12
- package/components/MapCluster.svelte.d.ts +2 -2
- package/components/MapPicker.svelte +5 -0
- package/components/MapPicker.svelte.d.ts +2 -0
- package/components/action-form-renderer-payload.d.ts +6 -0
- package/components/action-form-renderer-payload.ts +10 -1
- package/package.json +1 -1
- package/tokens/components.css +1 -1
- package/tokens/semantic.css +68 -0
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
import CodeBlock from "./CodeBlock.svelte";
|
|
8
8
|
import Input from "./Input.svelte";
|
|
9
9
|
import MapPicker from "./MapPicker.svelte";
|
|
10
|
+
import FileUpload from "./FileUpload.svelte";
|
|
11
|
+
import FileUploadItem from "./FileUploadItem.svelte";
|
|
10
12
|
import Select from "./Select.svelte";
|
|
11
13
|
import Tag from "./Tag.svelte";
|
|
12
14
|
import type { Snippet } from "svelte";
|
|
@@ -62,6 +64,11 @@
|
|
|
62
64
|
/** Injected apply. Required for a working submit button; absent → no button
|
|
63
65
|
* (so admin-preview / adapter-preview stay read-only). */
|
|
64
66
|
onApply?: (payload: Record<string, unknown>) => Promise<ApplyResult>;
|
|
67
|
+
/** Transport for `file`-typed parameters (#75 M5 slice 4b): receives one
|
|
68
|
+
* validated File, returns its storage key. The renderer owns the UI +
|
|
69
|
+
* the attachment_keys payload contract; the CONSUMER owns transport/auth
|
|
70
|
+
* (FileUpload philosophy). Omitted → file params render disabled. */
|
|
71
|
+
uploadFile?: (file: File) => Promise<{ key: string } | { error: string }>;
|
|
65
72
|
/** Environment-specific captcha (e.g. Turnstile), rendered above the button
|
|
66
73
|
* in submit modes. */
|
|
67
74
|
captcha?: Snippet;
|
|
@@ -76,11 +83,22 @@
|
|
|
76
83
|
mode = "admin-preview",
|
|
77
84
|
schema = null,
|
|
78
85
|
onApply = undefined,
|
|
86
|
+
uploadFile = undefined,
|
|
79
87
|
captcha = undefined,
|
|
80
88
|
submitLabel = "Submit",
|
|
81
89
|
}: Props = $props();
|
|
82
90
|
|
|
83
91
|
let values = $state<Record<string, unknown>>({});
|
|
92
|
+
// `file` params live OUTSIDE values — their keys ride the payload's
|
|
93
|
+
// top-level attachment_keys, never raw_values (the form schema doesn't
|
|
94
|
+
// know them; validation would reject strays).
|
|
95
|
+
let fileUploads = $state<Record<string, Array<{ key: string; name: string }>>>({});
|
|
96
|
+
let fileBusy = $state<Record<string, boolean>>({});
|
|
97
|
+
let fileError = $state<Record<string, string>>({});
|
|
98
|
+
// Platform caps mirror the public upload surface (3 × 5MB, web image types).
|
|
99
|
+
const FILE_MAX_COUNT = 3;
|
|
100
|
+
const FILE_MAX_BYTES = 5 * 1024 * 1024;
|
|
101
|
+
const FILE_ACCEPT = "image/jpeg,image/png,image/webp";
|
|
84
102
|
|
|
85
103
|
// Submit state (apply seam). Only meaningful in submit modes with an onApply.
|
|
86
104
|
let submitting = $state(false);
|
|
@@ -124,7 +142,11 @@
|
|
|
124
142
|
const canSubmit = $derived(
|
|
125
143
|
visibleParameters
|
|
126
144
|
.filter((parameter) => parameter.required)
|
|
127
|
-
.every((parameter) =>
|
|
145
|
+
.every((parameter) =>
|
|
146
|
+
String(parameter.type ?? "") === "file"
|
|
147
|
+
? (fileUploads[parameterKey(parameter)] ?? []).length > 0
|
|
148
|
+
: !isEmpty(values[parameterKey(parameter)]),
|
|
149
|
+
),
|
|
128
150
|
);
|
|
129
151
|
|
|
130
152
|
async function handleSubmit(): Promise<void> {
|
|
@@ -287,6 +309,45 @@
|
|
|
287
309
|
: {};
|
|
288
310
|
}
|
|
289
311
|
|
|
312
|
+
async function handleFiles(key: string, files: File[]) {
|
|
313
|
+
if (!uploadFile) return;
|
|
314
|
+
fileError = { ...fileError, [key]: "" };
|
|
315
|
+
const existing = fileUploads[key] ?? [];
|
|
316
|
+
const room = FILE_MAX_COUNT - existing.length;
|
|
317
|
+
const batch = files.slice(0, room);
|
|
318
|
+
if (files.length > room) {
|
|
319
|
+
fileError = { ...fileError, [key]: `Up to ${FILE_MAX_COUNT} files` };
|
|
320
|
+
}
|
|
321
|
+
if (batch.length === 0) return;
|
|
322
|
+
fileBusy = { ...fileBusy, [key]: true };
|
|
323
|
+
try {
|
|
324
|
+
for (const file of batch) {
|
|
325
|
+
const result = await uploadFile(file);
|
|
326
|
+
if ("key" in result) {
|
|
327
|
+
fileUploads = {
|
|
328
|
+
...fileUploads,
|
|
329
|
+
[key]: [...(fileUploads[key] ?? []), { key: result.key, name: file.name }],
|
|
330
|
+
};
|
|
331
|
+
} else {
|
|
332
|
+
fileError = { ...fileError, [key]: result.error };
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
} finally {
|
|
336
|
+
fileBusy = { ...fileBusy, [key]: false };
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function removeFile(key: string, storageKey: string) {
|
|
341
|
+
fileUploads = {
|
|
342
|
+
...fileUploads,
|
|
343
|
+
[key]: (fileUploads[key] ?? []).filter((f) => f.key !== storageKey),
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function allAttachmentKeys(): string[] {
|
|
348
|
+
return Object.values(fileUploads).flatMap((list) => list.map((f) => f.key));
|
|
349
|
+
}
|
|
350
|
+
|
|
290
351
|
function buildPayload(): Record<string, unknown> {
|
|
291
352
|
return buildActionPayload({
|
|
292
353
|
action: renderedAction ?? null,
|
|
@@ -294,6 +355,7 @@
|
|
|
294
355
|
targetConfig: targetConfig(),
|
|
295
356
|
sourceSchema: sourceSchema(),
|
|
296
357
|
rawValues: visibleValueBag(),
|
|
358
|
+
attachmentKeys: allAttachmentKeys(),
|
|
297
359
|
schemaVersion: schema?.schema_version ?? null,
|
|
298
360
|
mode,
|
|
299
361
|
}) as unknown as Record<string, unknown>;
|
|
@@ -350,11 +412,42 @@
|
|
|
350
412
|
]}
|
|
351
413
|
onchange={(value: string) => setValue(key, value === "true")}
|
|
352
414
|
/>
|
|
415
|
+
{:else if type === "file"}
|
|
416
|
+
<!-- `file` parameter (#75 M5 slice 4b): upload-as-you-attach. The keys
|
|
417
|
+
ride payload.attachment_keys; raw_values never sees this param. -->
|
|
418
|
+
<div class="afr-file-param">
|
|
419
|
+
<span class="afr-file-label">{String(parameter.label ?? key)}</span>
|
|
420
|
+
{#each fileUploads[key] ?? [] as f (f.key)}
|
|
421
|
+
<FileUploadItem
|
|
422
|
+
name={f.name}
|
|
423
|
+
onremove={() => removeFile(key, f.key)}
|
|
424
|
+
/>
|
|
425
|
+
{/each}
|
|
426
|
+
{#if (fileUploads[key] ?? []).length < FILE_MAX_COUNT}
|
|
427
|
+
<FileUpload
|
|
428
|
+
accept={FILE_ACCEPT}
|
|
429
|
+
maxSize={FILE_MAX_BYTES}
|
|
430
|
+
multiple
|
|
431
|
+
disabled={!uploadFile || fileBusy[key]}
|
|
432
|
+
onfiles={(files: File[]) => handleFiles(key, files)}
|
|
433
|
+
onreject={() => {
|
|
434
|
+
fileError = { ...fileError, [key]: "JPEG, PNG or WebP up to 5MB" };
|
|
435
|
+
}}
|
|
436
|
+
/>
|
|
437
|
+
{/if}
|
|
438
|
+
{#if fileError[key]}
|
|
439
|
+
<p class="afr-file-error" role="alert">{fileError[key]}</p>
|
|
440
|
+
{/if}
|
|
441
|
+
</div>
|
|
353
442
|
{:else if type === "geo"}
|
|
354
443
|
<!-- A `geo` parameter renders the DS map-picker; its value is the
|
|
355
444
|
[lon, lat] the BFF's GEO_PARSE binding transform turns into a Point.
|
|
356
445
|
The renderer stays generic — geo is just another param type, the
|
|
357
|
-
occurrence/location coupling lives entirely in the ontology binding.
|
|
446
|
+
occurrence/location coupling lives entirely in the ontology binding.
|
|
447
|
+
SIBLING-ADDRESS CONVENTION: when the form also declares a parameter
|
|
448
|
+
keyed `address`, the picker's resolved address (search pick or
|
|
449
|
+
reverse geocode) auto-fills it — tracking the pin; the citizen can
|
|
450
|
+
still edit the field afterwards (a later pin overwrites). -->
|
|
358
451
|
<MapPicker
|
|
359
452
|
mode="point"
|
|
360
453
|
height="20rem"
|
|
@@ -366,6 +459,11 @@
|
|
|
366
459
|
? (values[key] as [number, number])
|
|
367
460
|
: undefined}
|
|
368
461
|
onchange={(coords: [number, number]) => setValue(key, coords)}
|
|
462
|
+
onaddress={(displayName: string) => {
|
|
463
|
+
if (renderedParameters.some((p) => parameterKey(p) === "address")) {
|
|
464
|
+
setValue("address", displayName);
|
|
465
|
+
}
|
|
466
|
+
}}
|
|
369
467
|
/>
|
|
370
468
|
{:else}
|
|
371
469
|
<Input
|
|
@@ -538,6 +636,23 @@
|
|
|
538
636
|
gap: var(--space-lg);
|
|
539
637
|
}
|
|
540
638
|
|
|
639
|
+
.afr-file-param {
|
|
640
|
+
display: flex;
|
|
641
|
+
flex-direction: column;
|
|
642
|
+
gap: var(--space-xs);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
.afr-file-label {
|
|
646
|
+
font-size: var(--input-label-size, var(--type-body-sm-size));
|
|
647
|
+
color: var(--color-text-secondary);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
.afr-file-error {
|
|
651
|
+
margin: 0;
|
|
652
|
+
font-size: var(--type-body-sm-size);
|
|
653
|
+
color: var(--input-error-text);
|
|
654
|
+
}
|
|
655
|
+
|
|
541
656
|
.submit-captcha {
|
|
542
657
|
min-height: var(--space-4xl);
|
|
543
658
|
}
|
|
@@ -45,6 +45,15 @@ interface Props {
|
|
|
45
45
|
/** Injected apply. Required for a working submit button; absent → no button
|
|
46
46
|
* (so admin-preview / adapter-preview stay read-only). */
|
|
47
47
|
onApply?: (payload: Record<string, unknown>) => Promise<ApplyResult>;
|
|
48
|
+
/** Transport for `file`-typed parameters (#75 M5 slice 4b): receives one
|
|
49
|
+
* validated File, returns its storage key. The renderer owns the UI +
|
|
50
|
+
* the attachment_keys payload contract; the CONSUMER owns transport/auth
|
|
51
|
+
* (FileUpload philosophy). Omitted → file params render disabled. */
|
|
52
|
+
uploadFile?: (file: File) => Promise<{
|
|
53
|
+
key: string;
|
|
54
|
+
} | {
|
|
55
|
+
error: string;
|
|
56
|
+
}>;
|
|
48
57
|
/** Environment-specific captcha (e.g. Turnstile), rendered above the button
|
|
49
58
|
* in submit modes. */
|
|
50
59
|
captcha?: Snippet;
|
|
@@ -43,6 +43,10 @@
|
|
|
43
43
|
viewbox = undefined,
|
|
44
44
|
/** @type {((lon: number, lat: number, item: { label: string, value: string }) => void) | undefined} */
|
|
45
45
|
onlocation = undefined,
|
|
46
|
+
/** @type {((displayName: string) => void) | undefined} — fires with the
|
|
47
|
+
* resolved address label on BOTH paths: reverse geocode (map click /
|
|
48
|
+
* coords set) and forward selection. Consumers feed address fields. */
|
|
49
|
+
onresolved = undefined,
|
|
46
50
|
/** @type {[number, number] | undefined} — set externally to reverse-geocode and show address */
|
|
47
51
|
coords = $bindable(undefined),
|
|
48
52
|
/** @type {string} */
|
|
@@ -78,6 +82,7 @@
|
|
|
78
82
|
if (result.display_name) {
|
|
79
83
|
value = `${lon},${lat}`;
|
|
80
84
|
items = [{ value: `${lon},${lat}`, label: result.display_name, description: result.type?.replace(/_/g, ' ') ?? '' }];
|
|
85
|
+
onresolved?.(result.display_name);
|
|
81
86
|
}
|
|
82
87
|
} catch {
|
|
83
88
|
// Reverse geocoding failed — leave search bar as-is
|
|
@@ -135,9 +140,10 @@
|
|
|
135
140
|
const [lonStr, latStr] = val.split(',');
|
|
136
141
|
const lon = parseFloat(lonStr);
|
|
137
142
|
const lat = parseFloat(latStr);
|
|
138
|
-
if (!Number.isNaN(lon) && !Number.isNaN(lat)
|
|
143
|
+
if (!Number.isNaN(lon) && !Number.isNaN(lat)) {
|
|
139
144
|
const selectedItem = items.find((i) => i.value === val);
|
|
140
|
-
|
|
145
|
+
if (selectedItem?.label) onresolved?.(selectedItem.label);
|
|
146
|
+
onlocation?.(lon, lat, selectedItem ?? { label: '', value: val });
|
|
141
147
|
}
|
|
142
148
|
}
|
|
143
149
|
</script>
|
|
@@ -36,6 +36,7 @@ declare const GeoSearch: import("svelte").Component<{
|
|
|
36
36
|
providerUrl?: string;
|
|
37
37
|
viewbox?: any;
|
|
38
38
|
onlocation?: any;
|
|
39
|
+
onresolved?: any;
|
|
39
40
|
coords?: any;
|
|
40
41
|
class?: string;
|
|
41
42
|
} & Record<string, any>, {}, "coords">;
|
|
@@ -49,6 +50,7 @@ type $$ComponentProps = {
|
|
|
49
50
|
providerUrl?: string;
|
|
50
51
|
viewbox?: any;
|
|
51
52
|
onlocation?: any;
|
|
53
|
+
onresolved?: any;
|
|
52
54
|
coords?: any;
|
|
53
55
|
class?: string;
|
|
54
56
|
} & Record<string, any>;
|
|
@@ -14,13 +14,17 @@
|
|
|
14
14
|
-->
|
|
15
15
|
<script>
|
|
16
16
|
import { fromLonLat } from 'ol/proj.js';
|
|
17
|
+
import { boundingExtent } from 'ol/extent.js';
|
|
17
18
|
import { createTileLayer, createMapStyles, watchTheme, renderMapError } from './map-utils.js';
|
|
18
19
|
|
|
19
20
|
let {
|
|
20
21
|
/** @type {{ id: string, lon: number, lat: number, label?: string, [key: string]: any }[]} */
|
|
21
22
|
markers = [],
|
|
22
|
-
/** @type {[number, number]} — initial center [lon, lat]
|
|
23
|
-
|
|
23
|
+
/** @type {[number, number] | undefined} — initial center [lon, lat].
|
|
24
|
+
* Omit to auto-FIT the markers' extent (the right default for "show
|
|
25
|
+
* my data" maps; explicit center wins for dashboards pinned to a
|
|
26
|
+
* region). */
|
|
27
|
+
center = undefined,
|
|
24
28
|
/** @type {number} */
|
|
25
29
|
zoom = 6,
|
|
26
30
|
/** @type {number} — cluster distance in pixels */
|
|
@@ -51,13 +55,21 @@
|
|
|
51
55
|
/** @type {any} — Point constructor */
|
|
52
56
|
let _Point;
|
|
53
57
|
|
|
54
|
-
// Reactive: animate
|
|
58
|
+
// Reactive: animate when the CALLER's center/zoom props change. Guarded
|
|
59
|
+
// against the mount-time run — with no explicit `center` the view was
|
|
60
|
+
// initialised to the markers' extent/mean, and animating to the prop
|
|
61
|
+
// default ([0,0], null island) shoved every marker off-screen.
|
|
62
|
+
let _lastCenterKey = $state("");
|
|
55
63
|
$effect(() => {
|
|
56
|
-
|
|
64
|
+
const key = center ? `${center[0]},${center[1]},${zoom}` : "";
|
|
65
|
+
if (!_map || !center) return;
|
|
66
|
+
if (key === _lastCenterKey) return;
|
|
67
|
+
const first = _lastCenterKey === "";
|
|
68
|
+
_lastCenterKey = key;
|
|
69
|
+
if (first) return; // initial view already honours the props
|
|
57
70
|
const view = _map.getView();
|
|
58
71
|
if (!view) return;
|
|
59
|
-
|
|
60
|
-
view.animate({ center: targetCenter, zoom, duration: 300 });
|
|
72
|
+
view.animate({ center: fromLonLat(center), zoom, duration: 300 });
|
|
61
73
|
});
|
|
62
74
|
|
|
63
75
|
// Reactive: update markers when props change
|
|
@@ -155,14 +167,22 @@
|
|
|
155
167
|
element: tooltipEl,
|
|
156
168
|
positioning: 'bottom-center',
|
|
157
169
|
offset: [0, -12],
|
|
170
|
+
// The hover tooltip anchors AT the feature — OL's default
|
|
171
|
+
// stopEvent:true puts it in the event-stopping overlay pane, so the
|
|
172
|
+
// very click the tooltip invites lands on the overlay container and
|
|
173
|
+
// dies (the element's own pointer-events:none can't help; the
|
|
174
|
+
// CONTAINER captures). Informational overlay → never stop events.
|
|
175
|
+
stopEvent: false,
|
|
158
176
|
});
|
|
159
177
|
|
|
160
|
-
const viewCenter =
|
|
161
|
-
? fromLonLat(
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
178
|
+
const viewCenter = center
|
|
179
|
+
? fromLonLat(center)
|
|
180
|
+
: markers.length > 0
|
|
181
|
+
? fromLonLat([
|
|
182
|
+
markers.reduce((s, m) => s + m.lon, 0) / markers.length,
|
|
183
|
+
markers.reduce((s, m) => s + m.lat, 0) / markers.length,
|
|
184
|
+
])
|
|
185
|
+
: fromLonLat([0, 0]);
|
|
166
186
|
|
|
167
187
|
map = new OlMap({
|
|
168
188
|
target: container,
|
|
@@ -174,6 +194,14 @@
|
|
|
174
194
|
}),
|
|
175
195
|
});
|
|
176
196
|
_map = map;
|
|
197
|
+
// No explicit center → fit the markers' extent so every cluster is on
|
|
198
|
+
// screen regardless of how the data spreads (mean-centre alone leaves
|
|
199
|
+
// distant clouds outside the default zoom).
|
|
200
|
+
if (!center && markers.length > 1) {
|
|
201
|
+
const coords = markers.map((m) => fromLonLat([m.lon, m.lat]));
|
|
202
|
+
const ext = boundingExtent(coords);
|
|
203
|
+
map.getView().fit(ext, { padding: [48, 48, 48, 48], maxZoom: 15 });
|
|
204
|
+
}
|
|
177
205
|
|
|
178
206
|
// Hover: show tooltip
|
|
179
207
|
map.on('pointermove', (evt) => {
|
|
@@ -212,6 +240,18 @@
|
|
|
212
240
|
const data = clustered[0].get('markerData');
|
|
213
241
|
if (data) onclick(data);
|
|
214
242
|
} else if (clustered?.length > 1) {
|
|
243
|
+
// A cluster of (near-)IDENTICAL coordinates can never split by
|
|
244
|
+
// zooming (stacked reports at one point are common in civic
|
|
245
|
+
// data) — open the first item instead of zoom-looping forever.
|
|
246
|
+
const coords = clustered.map((f) => f.getGeometry()?.getCoordinates()).filter(Boolean);
|
|
247
|
+
const lons = coords.map((c) => c[0]);
|
|
248
|
+
const lats = coords.map((c) => c[1]);
|
|
249
|
+
const spread = Math.max(...lons) - Math.min(...lons) + (Math.max(...lats) - Math.min(...lats));
|
|
250
|
+
if (spread < 1) { // metres in web-mercator units — a stacked pile
|
|
251
|
+
const data = clustered[0].get('markerData');
|
|
252
|
+
if (data) onclick(data);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
215
255
|
const view = map?.getView();
|
|
216
256
|
const currentZoom = view?.getZoom() ?? zoom;
|
|
217
257
|
view?.animate({
|
|
@@ -19,7 +19,7 @@ type MapCluster = {
|
|
|
19
19
|
*/
|
|
20
20
|
declare const MapCluster: import("svelte").Component<{
|
|
21
21
|
markers?: any[];
|
|
22
|
-
center?: any
|
|
22
|
+
center?: any;
|
|
23
23
|
zoom?: number;
|
|
24
24
|
distance?: number;
|
|
25
25
|
tileSource?: Record<string, any>;
|
|
@@ -29,7 +29,7 @@ declare const MapCluster: import("svelte").Component<{
|
|
|
29
29
|
} & Record<string, any>, {}, "">;
|
|
30
30
|
type $$ComponentProps = {
|
|
31
31
|
markers?: any[];
|
|
32
|
-
center?: any
|
|
32
|
+
center?: any;
|
|
33
33
|
zoom?: number;
|
|
34
34
|
distance?: number;
|
|
35
35
|
tileSource?: Record<string, any>;
|
|
@@ -52,6 +52,10 @@
|
|
|
52
52
|
tileSource = { type: 'osm' },
|
|
53
53
|
/** @type {((coords: [number, number] | number[][]) => void) | undefined} */
|
|
54
54
|
onchange = undefined,
|
|
55
|
+
/** @type {((displayName: string) => void) | undefined} — the resolved
|
|
56
|
+
* address whenever the pin settles (search pick or reverse geocode of a
|
|
57
|
+
* map click). Feed it to an address field; coords stay `onchange`. */
|
|
58
|
+
onaddress = undefined,
|
|
55
59
|
/** @type {string} */
|
|
56
60
|
height = '100%',
|
|
57
61
|
/** @type {string | undefined} */
|
|
@@ -234,6 +238,7 @@
|
|
|
234
238
|
providerUrl={searchProviderUrl}
|
|
235
239
|
viewbox={searchViewbox}
|
|
236
240
|
onlocation={handleGeoLocation}
|
|
241
|
+
onresolved={onaddress}
|
|
237
242
|
bind:coords={searchCoords}
|
|
238
243
|
size="sm"
|
|
239
244
|
/>
|
|
@@ -35,6 +35,7 @@ declare const MapPicker: import("svelte").Component<{
|
|
|
35
35
|
searchViewbox?: any;
|
|
36
36
|
tileSource?: Record<string, any>;
|
|
37
37
|
onchange?: any;
|
|
38
|
+
onaddress?: any;
|
|
38
39
|
height?: string;
|
|
39
40
|
id?: any;
|
|
40
41
|
class?: string;
|
|
@@ -53,6 +54,7 @@ type $$ComponentProps = {
|
|
|
53
54
|
searchViewbox?: any;
|
|
54
55
|
tileSource?: Record<string, any>;
|
|
55
56
|
onchange?: any;
|
|
57
|
+
onaddress?: any;
|
|
56
58
|
height?: string;
|
|
57
59
|
id?: any;
|
|
58
60
|
class?: string;
|
|
@@ -39,10 +39,16 @@ export interface BuildArgs {
|
|
|
39
39
|
sourceSchema: Record<string, unknown>;
|
|
40
40
|
/** Submitted values keyed by parameter source_field_path or key. */
|
|
41
41
|
rawValues: Record<string, unknown>;
|
|
42
|
+
/** Uploaded file storage keys (`file`-typed parameters) — the platform
|
|
43
|
+
* attachment contract: a TOP-LEVEL `attachment_keys` sibling, never part
|
|
44
|
+
* of raw_values (the form schema doesn't validate file params). */
|
|
45
|
+
attachmentKeys?: string[];
|
|
42
46
|
schemaVersion: string | null;
|
|
43
47
|
mode: RendererMode;
|
|
44
48
|
}
|
|
45
49
|
export interface ActionPayload {
|
|
50
|
+
/** Uploaded file storage keys (file-typed params) — absent when none. */
|
|
51
|
+
attachment_keys?: string[];
|
|
46
52
|
source: string;
|
|
47
53
|
action: {
|
|
48
54
|
id: string | null;
|
|
@@ -42,11 +42,17 @@ export interface BuildArgs {
|
|
|
42
42
|
sourceSchema: Record<string, unknown>;
|
|
43
43
|
/** Submitted values keyed by parameter source_field_path or key. */
|
|
44
44
|
rawValues: Record<string, unknown>;
|
|
45
|
+
/** Uploaded file storage keys (`file`-typed parameters) — the platform
|
|
46
|
+
* attachment contract: a TOP-LEVEL `attachment_keys` sibling, never part
|
|
47
|
+
* of raw_values (the form schema doesn't validate file params). */
|
|
48
|
+
attachmentKeys?: string[];
|
|
45
49
|
schemaVersion: string | null;
|
|
46
50
|
mode: RendererMode;
|
|
47
51
|
}
|
|
48
52
|
|
|
49
53
|
export interface ActionPayload {
|
|
54
|
+
/** Uploaded file storage keys (file-typed params) — absent when none. */
|
|
55
|
+
attachment_keys?: string[];
|
|
50
56
|
source: string;
|
|
51
57
|
action: {
|
|
52
58
|
id: string | null;
|
|
@@ -102,7 +108,7 @@ function pickScope(targetConfig: Record<string, unknown>): Record<string, unknow
|
|
|
102
108
|
}
|
|
103
109
|
|
|
104
110
|
export function buildActionPayload(args: BuildArgs): ActionPayload {
|
|
105
|
-
const { action, placement, targetConfig, sourceSchema, rawValues, schemaVersion, mode } = args;
|
|
111
|
+
const { action, placement, targetConfig, sourceSchema, rawValues, schemaVersion, mode, attachmentKeys } = args;
|
|
106
112
|
|
|
107
113
|
const sourceFromSchema = nullableString(sourceSchema.source);
|
|
108
114
|
const targetModel =
|
|
@@ -143,6 +149,9 @@ export function buildActionPayload(args: BuildArgs): ActionPayload {
|
|
|
143
149
|
target_model: targetModel,
|
|
144
150
|
form_definition_id: nullableString(targetConfig.form_definition_id),
|
|
145
151
|
},
|
|
152
|
+
...(attachmentKeys && attachmentKeys.length > 0
|
|
153
|
+
? { attachment_keys: attachmentKeys }
|
|
154
|
+
: {}),
|
|
146
155
|
};
|
|
147
156
|
}
|
|
148
157
|
|
package/package.json
CHANGED
package/tokens/components.css
CHANGED
|
@@ -514,7 +514,7 @@
|
|
|
514
514
|
POPOVER
|
|
515
515
|
═══════════════════════════════════════════════ */
|
|
516
516
|
|
|
517
|
-
--popover-bg: var(--
|
|
517
|
+
--popover-bg: var(--color-surface-raised);
|
|
518
518
|
--popover-border: var(--elevation-border-strong);
|
|
519
519
|
--popover-shadow: var(--elevation-overlay);
|
|
520
520
|
--popover-radius: var(--radius-md);
|
package/tokens/semantic.css
CHANGED
|
@@ -18,6 +18,9 @@
|
|
|
18
18
|
--color-surface: var(--raw-color-neutral-50);
|
|
19
19
|
--color-surface-secondary: var(--raw-color-neutral-100);
|
|
20
20
|
--color-surface-tertiary: var(--raw-color-neutral-200);
|
|
21
|
+
/* Raised surface — popovers/menus that float ABOVE the page surface
|
|
22
|
+
(lighter than the page in light scheme, lighter-than-base in dark). */
|
|
23
|
+
--color-surface-raised: var(--raw-color-white);
|
|
21
24
|
--color-overlay: rgba(44, 40, 37, 0.5);
|
|
22
25
|
|
|
23
26
|
/* Borders */
|
|
@@ -216,6 +219,71 @@
|
|
|
216
219
|
--content-padding-y: var(--space-lg);
|
|
217
220
|
}
|
|
218
221
|
|
|
222
|
+
/* ═══════════════════════════════════════════════
|
|
223
|
+
DARK SCHEME — `<html data-scheme="dark">`
|
|
224
|
+
═══════════════════════════════════════════════
|
|
225
|
+
|
|
226
|
+
One generic layer over the NEUTRAL-derived roles (surfaces, borders,
|
|
227
|
+
text, overlay). Brand hues survive untouched — the accent stays the
|
|
228
|
+
theme's accent — while every `-subtle` wash is re-derived from its
|
|
229
|
+
live hue via color-mix, so a tenant theme's accent gets a correct
|
|
230
|
+
dark wash for free. Per-theme bespoke dark = a `[data-theme="x"]`
|
|
231
|
+
block in the theme file, not here.
|
|
232
|
+
|
|
233
|
+
Selector is `:root[data-scheme="dark"]` (0,1,1): it TIES tenant theme
|
|
234
|
+
blocks (`:root[data-theme="x"]`, runtime-injected in <head>) and wins
|
|
235
|
+
on order — DS styles load after the injected theme block. Consumers
|
|
236
|
+
resolving an "auto" preference must set the RESOLVED value on <html>
|
|
237
|
+
(a pre-paint `prefers-color-scheme` read), never "auto" itself. */
|
|
238
|
+
|
|
239
|
+
:root[data-scheme="dark"] {
|
|
240
|
+
color-scheme: dark;
|
|
241
|
+
|
|
242
|
+
/* Surfaces — darkest at the base, lighter as elevation rises */
|
|
243
|
+
--color-surface: var(--raw-color-neutral-950);
|
|
244
|
+
--color-surface-secondary: var(--raw-color-neutral-900);
|
|
245
|
+
--color-surface-tertiary: var(--raw-color-neutral-800);
|
|
246
|
+
--color-surface-raised: var(--raw-color-neutral-900);
|
|
247
|
+
--color-overlay: rgba(0, 0, 0, 0.65);
|
|
248
|
+
|
|
249
|
+
/* Borders */
|
|
250
|
+
--color-border: var(--raw-color-neutral-800);
|
|
251
|
+
--color-border-strong: var(--raw-color-neutral-700);
|
|
252
|
+
|
|
253
|
+
/* Text — warm off-whites off the same neutral ramp */
|
|
254
|
+
--color-text: var(--raw-color-neutral-100);
|
|
255
|
+
--color-text-secondary: var(--raw-color-neutral-400);
|
|
256
|
+
--color-text-muted: var(--raw-color-neutral-500);
|
|
257
|
+
|
|
258
|
+
/* Subtle washes — derived from the LIVE hue (theme-set or default),
|
|
259
|
+
mixed into the dark surface instead of the light-only raw tints. */
|
|
260
|
+
--color-accent-subtle: color-mix(
|
|
261
|
+
in srgb,
|
|
262
|
+
var(--color-accent) 16%,
|
|
263
|
+
var(--raw-color-neutral-950)
|
|
264
|
+
);
|
|
265
|
+
--color-destructive-subtle: color-mix(
|
|
266
|
+
in srgb,
|
|
267
|
+
var(--color-destructive) 14%,
|
|
268
|
+
var(--raw-color-neutral-950)
|
|
269
|
+
);
|
|
270
|
+
--color-success-subtle: color-mix(
|
|
271
|
+
in srgb,
|
|
272
|
+
var(--color-success) 14%,
|
|
273
|
+
var(--raw-color-neutral-950)
|
|
274
|
+
);
|
|
275
|
+
--color-warning-subtle: color-mix(
|
|
276
|
+
in srgb,
|
|
277
|
+
var(--color-warning) 14%,
|
|
278
|
+
var(--raw-color-neutral-950)
|
|
279
|
+
);
|
|
280
|
+
--color-info-subtle: color-mix(
|
|
281
|
+
in srgb,
|
|
282
|
+
var(--color-info) 14%,
|
|
283
|
+
var(--raw-color-neutral-950)
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
219
287
|
/* ─── Tablet (768px+) ─── */
|
|
220
288
|
@media (min-width: 768px) {
|
|
221
289
|
:root {
|