@aiaiai-pt/design-system 0.43.0 → 0.44.1

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.
@@ -5,6 +5,8 @@
5
5
  import Button from "./Button.svelte";
6
6
  import Alert from "./Alert.svelte";
7
7
  import CodeBlock from "./CodeBlock.svelte";
8
+ import Combobox from "./Combobox.svelte";
9
+ import DateTimePicker from "./DateTimePicker.svelte";
8
10
  import Input from "./Input.svelte";
9
11
  import MapPicker from "./MapPicker.svelte";
10
12
  import FileUpload from "./FileUpload.svelte";
@@ -69,6 +71,17 @@
69
71
  * the attachment_keys payload contract; the CONSUMER owns transport/auth
70
72
  * (FileUpload philosophy). Omitted → file params render disabled. */
71
73
  uploadFile?: (file: File) => Promise<{ key: string } | { error: string }>;
74
+ /** Lookup for `object_reference`-typed parameters (#629 PR1b): receives the
75
+ * parameter's `object_type` (the entity type code), the operator's query,
76
+ * and the parameter's optional `object_filter`; returns picker items. The
77
+ * renderer owns the Combobox UI + value plumbing; the CONSUMER owns
78
+ * transport/auth/tenancy (the uploadFile seam pattern — the DS stays
79
+ * presentation-only). Omitted → object_reference params render disabled. */
80
+ searchEntities?: (
81
+ typeCode: string,
82
+ query: string,
83
+ filter?: Record<string, string>,
84
+ ) => Promise<SelectOption[]>;
72
85
  /** Environment-specific captcha (e.g. Turnstile), rendered above the button
73
86
  * in submit modes. */
74
87
  captcha?: Snippet;
@@ -112,6 +125,7 @@
112
125
  schema = null,
113
126
  onApply = undefined,
114
127
  uploadFile = undefined,
128
+ searchEntities = undefined,
115
129
  captcha = undefined,
116
130
  submitLabel = "Submit",
117
131
  boundary = undefined,
@@ -264,14 +278,20 @@
264
278
  }
265
279
 
266
280
  function sectionName(parameter: Entity): string {
267
- const visibility = parameter.visibility;
268
- if (visibility && typeof visibility === "object" && !Array.isArray(visibility)) {
269
- const section = (visibility as Record<string, unknown>).section;
270
- if (section) return String(section);
271
- }
281
+ // Mirrors the BFF `_parameter_section` precedence (action_schema.py):
282
+ // ui_schema.section_label ui_schema.section visibility.section_label
283
+ // → visibility.section "Details". `||` (not `??`) to match Python `or`
284
+ // falsy-chaining an empty string falls through to the next rung.
272
285
  const uiSchema = parameter.ui_schema;
273
286
  if (uiSchema && typeof uiSchema === "object" && !Array.isArray(uiSchema)) {
274
- const section = (uiSchema as Record<string, unknown>).section;
287
+ const row = uiSchema as Record<string, unknown>;
288
+ const section = row.section_label || row.section;
289
+ if (section) return String(section);
290
+ }
291
+ const visibility = parameter.visibility;
292
+ if (visibility && typeof visibility === "object" && !Array.isArray(visibility)) {
293
+ const row = visibility as Record<string, unknown>;
294
+ const section = row.section_label || row.section;
275
295
  if (section) return String(section);
276
296
  }
277
297
  return "Details";
@@ -400,6 +420,54 @@
400
420
  }
401
421
  }
402
422
 
423
+ // `object_reference` picker state (#629 PR1b): per-parameter items + loading
424
+ // for the lazy-search Combobox. A per-key generation counter drops stale
425
+ // responses (the host's validateGen pattern) so a slow early query can't
426
+ // clobber a fast later one.
427
+ let referenceItems = $state<Record<string, SelectOption[]>>({});
428
+ let referenceLoading = $state<Record<string, boolean>>({});
429
+ const referenceSearchGen: Record<string, number> = {};
430
+
431
+ function objectFilter(parameter: Entity): Record<string, string> | undefined {
432
+ const filter = parameter.object_filter;
433
+ return filter && typeof filter === "object" && !Array.isArray(filter)
434
+ ? (filter as Record<string, string>)
435
+ : undefined;
436
+ }
437
+
438
+ async function handleReferenceSearch(parameter: Entity, query: string) {
439
+ if (!searchEntities) return;
440
+ const key = parameterKey(parameter);
441
+ const gen = (referenceSearchGen[key] = (referenceSearchGen[key] ?? 0) + 1);
442
+ referenceLoading = { ...referenceLoading, [key]: true };
443
+ try {
444
+ const items = await searchEntities(
445
+ String(parameter.object_type ?? ""),
446
+ query,
447
+ objectFilter(parameter),
448
+ );
449
+ if (referenceSearchGen[key] !== gen) return;
450
+ referenceItems = { ...referenceItems, [key]: Array.isArray(items) ? items : [] };
451
+ } catch {
452
+ if (referenceSearchGen[key] === gen) referenceItems = { ...referenceItems, [key]: [] };
453
+ } finally {
454
+ if (referenceSearchGen[key] === gen)
455
+ referenceLoading = { ...referenceLoading, [key]: false };
456
+ }
457
+ }
458
+
459
+ // `datetime` params keep an ISO 8601 STRING in the value bag (the platform's
460
+ // datetime wire vocabulary — `$now` resolves to one) so the payload stays
461
+ // plain JSON; the DateTimePicker speaks Date on its prop edge.
462
+ function datetimeValue(value: unknown): Date | null {
463
+ if (value instanceof Date) return value;
464
+ if (typeof value === "string" && value) {
465
+ const parsed = new Date(value);
466
+ if (!Number.isNaN(parsed.getTime())) return parsed;
467
+ }
468
+ return null;
469
+ }
470
+
403
471
  function removeFile(key: string, storageKey: string) {
404
472
  fileUploads = {
405
473
  ...fileUploads,
@@ -411,6 +479,16 @@
411
479
  return Object.values(fileUploads).flatMap((list) => list.map((f) => f.key));
412
480
  }
413
481
 
482
+ /** #629 follow-up — the same keys ATTRIBUTED to the param each was
483
+ * uploaded against, so consumers never have to guess positionally. */
484
+ function attachmentsByParam(): Record<string, string[]> {
485
+ const out: Record<string, string[]> = {};
486
+ for (const [paramKey, list] of Object.entries(fileUploads)) {
487
+ if (list.length) out[paramKey] = list.map((f) => f.key);
488
+ }
489
+ return out;
490
+ }
491
+
414
492
  function buildPayload(): Record<string, unknown> {
415
493
  return buildActionPayload({
416
494
  action: renderedAction ?? null,
@@ -419,6 +497,7 @@
419
497
  sourceSchema: sourceSchema(),
420
498
  rawValues: visibleValueBag(),
421
499
  attachmentKeys: allAttachmentKeys(),
500
+ attachmentsByParam: attachmentsByParam(),
422
501
  schemaVersion: schema?.schema_version ?? null,
423
502
  mode,
424
503
  }) as unknown as Record<string, unknown>;
@@ -490,6 +569,33 @@
490
569
  onchange={(value: string) => setValue(key, value === "true")}
491
570
  aria-required={ariaRequired}
492
571
  />
572
+ {:else if type === "datetime"}
573
+ <!-- `datetime` parameter (#629 PR1b): the DS date+time picker. The value
574
+ bag carries an ISO 8601 string (see datetimeValue). `datetime` is the
575
+ platform's one wire token today (sheets + ActionParam docs). -->
576
+ <DateTimePicker
577
+ label={String(parameter.label ?? key)}
578
+ value={datetimeValue(values[key])}
579
+ readonly={!editable}
580
+ onchange={(date: Date) => setValue(key, date.toISOString())}
581
+ />
582
+ {:else if type === "object_reference"}
583
+ <!-- `object_reference` parameter (#629 PR1b): lazy-search Combobox over
584
+ the entity type named by `object_type`; `object_filter` rides the
585
+ injected searchEntities seam. No seam → disabled (the file-param
586
+ convention for a missing consumer transport). -->
587
+ <Combobox
588
+ label={String(parameter.label ?? key)}
589
+ items={referenceItems[key] ?? []}
590
+ value={String(values[key] ?? "")}
591
+ placeholder={typeof parameter.object_type === "string" && parameter.object_type
592
+ ? `Search ${parameter.object_type}...`
593
+ : "Search..."}
594
+ disabled={!searchEntities || !editable}
595
+ loading={referenceLoading[key] ?? false}
596
+ onsearch={(query: string) => handleReferenceSearch(parameter, query)}
597
+ onchange={(value: string) => setValue(key, value)}
598
+ />
493
599
  {:else if type === "file"}
494
600
  <!-- `file` parameter (#75 M5 slice 4b): upload-as-you-attach. The keys
495
601
  ride payload.attachment_keys; raw_values never sees this param. -->
@@ -567,8 +673,10 @@
567
673
  aria-required={ariaRequired}
568
674
  />
569
675
  {/if}
570
- {#if mode === "public-submit"}
571
- <!-- Citizen-facing: just a quiet required hint, no operator type debug. -->
676
+ {#if isSubmitMode}
677
+ <!-- EXECUTE surfaces (citizen public-submit AND staff admin-execute,
678
+ #629 follow-up): just a quiet required hint — the "Required / type"
679
+ line is operator DEBUG chrome, preview modes only. -->
572
680
  {#if parameter.required}<p class="field-meta">Required</p>{/if}
573
681
  {:else}
574
682
  <p class="field-meta">
@@ -580,8 +688,12 @@
580
688
  <div class="renderer" data-testid={`action-form-renderer-${mode}`} data-layout={resolvedLayout.key}>
581
689
  <!-- Wizard pagination (#105 P6): the ServiceFlow shell owns the step
582
690
  heading, so the renderer's own action-title header is suppressed when a
583
- section is active to avoid a duplicate heading per step. -->
584
- {#if activeSectionKey == null}
691
+ section is active to avoid a duplicate heading per step.
692
+ admin-execute (#629 follow-up): the HOST container (drawer/modal) owns
693
+ the action heading — the renderer's header would duplicate it, and the
694
+ "Placement preview" eyebrow + placement Badge are authoring chrome that
695
+ has no meaning on an execute surface. -->
696
+ {#if activeSectionKey == null && mode !== "admin-execute"}
585
697
  <div class="renderer-header">
586
698
  <div>
587
699
  <!-- The placement-preview eyebrow + surface badge are operator chrome —
@@ -8,6 +8,10 @@ import { type RendererMode } from "./action-form-renderer-payload";
8
8
  * compatible.
9
9
  */
10
10
  type Entity = Record<string, unknown>;
11
+ type SelectOption = {
12
+ value: string;
13
+ label: string;
14
+ };
11
15
  type ActionSchema = {
12
16
  found?: boolean;
13
17
  schema_version?: string;
@@ -54,6 +58,13 @@ interface Props {
54
58
  } | {
55
59
  error: string;
56
60
  }>;
61
+ /** Lookup for `object_reference`-typed parameters (#629 PR1b): receives the
62
+ * parameter's `object_type` (the entity type code), the operator's query,
63
+ * and the parameter's optional `object_filter`; returns picker items. The
64
+ * renderer owns the Combobox UI + value plumbing; the CONSUMER owns
65
+ * transport/auth/tenancy (the uploadFile seam pattern — the DS stays
66
+ * presentation-only). Omitted → object_reference params render disabled. */
67
+ searchEntities?: (typeCode: string, query: string, filter?: Record<string, string>) => Promise<SelectOption[]>;
57
68
  /** Environment-specific captcha (e.g. Turnstile), rendered above the button
58
69
  * in submit modes. */
59
70
  captcha?: Snippet;
@@ -18,6 +18,11 @@
18
18
  <Panel open persistent side="left" title="History">
19
19
  Always-visible sidebar content
20
20
  </Panel>
21
+
22
+ @example Modeless drawer (fixed slide-over, page behind stays interactive)
23
+ <Panel open modeless title="Execute action" onclose={() => showAction = false}>
24
+ Action form here — focus moves to the heading, Escape closes while inside
25
+ </Panel>
21
26
  -->
22
27
  <script module>
23
28
  let _panelUid = 0;
@@ -40,6 +45,14 @@
40
45
  side = 'right',
41
46
  /** @type {boolean} When true, removes backdrop and focus trap. Panel becomes an inline layout element. */
42
47
  persistent = false,
48
+ /** @type {boolean} Modeless drawer (#629): stays a FIXED slide-over like the
49
+ * default mode, but with no backdrop, no aria-modal, and no focus trap —
50
+ * the page behind remains browsable/interactive. On open the panel is a
51
+ * labelled region and focus MOVES to its heading (announced, not trapped);
52
+ * Escape closes only while focus is inside the panel; focus returns to the
53
+ * previously-focused element on close (the host owns any deep-link
54
+ * fallback). Ignored when `persistent` is set. */
55
+ modeless = false,
43
56
  /** @type {boolean} When false, body doesn't scroll — children manage their own overflow. */
44
57
  scrollBody = true,
45
58
  /** @type {(() => void) | undefined} */
@@ -62,10 +75,17 @@
62
75
 
63
76
  const FOCUSABLE = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
64
77
 
78
+ // Resolved mode: persistent wins over modeless (persistent consumers are
79
+ // inline layout elements — a fixed slide-over would be a breaking change).
80
+ const isPersistent = $derived(persistent);
81
+ const isModeless = $derived(modeless && !persistent);
82
+ const isDialog = $derived(!persistent && !modeless);
83
+
65
84
  // Focus trap: save previous focus, trap Tab, restore on close
66
- // Skipped in persistent mode (panel is inline, not modal)
85
+ // Skipped in persistent and modeless modes (persistent is inline; modeless
86
+ // deliberately leaves the page behind reachable — focus moved, not trapped)
67
87
  $effect(() => {
68
- if (!open || !panelEl || persistent) return;
88
+ if (!open || !panelEl || !isDialog) return;
69
89
 
70
90
  const previouslyFocused = /** @type {HTMLElement | null} */ (document.activeElement);
71
91
 
@@ -107,11 +127,41 @@
107
127
  function handleBackdropClick() {
108
128
  onclose?.();
109
129
  }
130
+
131
+ // Modeless drawer (#629): initial focus to the panel heading (announces the
132
+ // labelled region without trapping), Escape scoped to the PANEL element so
133
+ // it closes only while focus is inside, previous focus restored on close.
134
+ $effect(() => {
135
+ if (!open || !panelEl || !isModeless) return;
136
+
137
+ const previouslyFocused = /** @type {HTMLElement | null} */ (document.activeElement);
138
+
139
+ const heading = /** @type {HTMLElement | null} */ (
140
+ panelEl.querySelector('.panel-heading-focus')
141
+ );
142
+ heading?.focus();
143
+
144
+ /** @param {KeyboardEvent} e */
145
+ function handlePanelKeydown(e) {
146
+ if (e.key === 'Escape') onclose?.();
147
+ }
148
+
149
+ panelEl.addEventListener('keydown', handlePanelKeydown);
150
+
151
+ return () => {
152
+ panelEl?.removeEventListener('keydown', handlePanelKeydown);
153
+ // Deep-link entries have no meaningful previous focus (body) — the HOST
154
+ // owns that fallback (e.g. page H1); restoring a disconnected node is a
155
+ // no-op by contract.
156
+ if (previouslyFocused?.isConnected) previouslyFocused.focus();
157
+ };
158
+ });
110
159
  </script>
111
160
 
112
161
  {#if open}
113
- <!-- Backdrop (hidden in persistent mode) -->
114
- {#if !persistent}
162
+ <!-- Backdrop (dialog mode only — persistent is inline, modeless leaves the
163
+ page behind visible and interactive) -->
164
+ {#if isDialog}
115
165
  <div
116
166
  class="panel-backdrop"
117
167
  onclick={handleBackdropClick}
@@ -122,18 +172,25 @@
122
172
  <!-- Panel -->
123
173
  <aside
124
174
  bind:this={panelEl}
125
- class="panel panel-{width} panel-side-{side} {persistent ? 'panel-persistent' : ''} {className}"
126
- role={persistent ? 'complementary' : 'dialog'}
127
- aria-modal={persistent ? undefined : 'true'}
175
+ class="panel panel-{width} panel-side-{side} {isPersistent ? 'panel-persistent' : ''} {className}"
176
+ role={isPersistent ? 'complementary' : isModeless ? 'region' : 'dialog'}
177
+ aria-modal={isDialog ? 'true' : undefined}
128
178
  aria-label={!header ? title : undefined}
129
179
  aria-labelledby={header ? headerId : undefined}
130
180
  {...rest}
131
181
  >
132
182
  <div class="panel-header">
133
183
  {#if header}
134
- <div id={headerId}>{@render header()}</div>
184
+ <div
185
+ id={headerId}
186
+ class="panel-heading-focus"
187
+ tabindex={isModeless ? -1 : undefined}
188
+ >{@render header()}</div>
135
189
  {:else if title}
136
- <h2 class="panel-title">{title}</h2>
190
+ <h2
191
+ class="panel-title panel-heading-focus"
192
+ tabindex={isModeless ? -1 : undefined}
193
+ >{title}</h2>
137
194
  {/if}
138
195
  {#if !persistent}
139
196
  <button
@@ -23,6 +23,11 @@ type Panel = {
23
23
  * <Panel open persistent side="left" title="History">
24
24
  * Always-visible sidebar content
25
25
  * </Panel>
26
+ *
27
+ * @example Modeless drawer (fixed slide-over, page behind stays interactive)
28
+ * <Panel open modeless title="Execute action" onclose={() => showAction = false}>
29
+ * Action form here — focus moves to the heading, Escape closes while inside
30
+ * </Panel>
26
31
  */
27
32
  declare const Panel: import("svelte").Component<{
28
33
  open?: boolean;
@@ -30,6 +35,7 @@ declare const Panel: import("svelte").Component<{
30
35
  width?: string;
31
36
  side?: string;
32
37
  persistent?: boolean;
38
+ modeless?: boolean;
33
39
  scrollBody?: boolean;
34
40
  onclose: any;
35
41
  class?: string;
@@ -43,6 +49,7 @@ type $$ComponentProps = {
43
49
  width?: string;
44
50
  side?: string;
45
51
  persistent?: boolean;
52
+ modeless?: boolean;
46
53
  scrollBody?: boolean;
47
54
  onclose: any;
48
55
  class?: string;
@@ -43,12 +43,20 @@ export interface BuildArgs {
43
43
  * attachment contract: a TOP-LEVEL `attachment_keys` sibling, never part
44
44
  * of raw_values (the form schema doesn't validate file params). */
45
45
  attachmentKeys?: string[];
46
+ /** #629 follow-up — the SAME storage keys, attributed to the file param
47
+ * each was uploaded against (`{param_key: [keys]}`). Additive: consumers
48
+ * that only read the flat `attachment_keys` are unaffected; consumers
49
+ * that must map keys back to parameters (the staff execute adapter) read
50
+ * this instead of guessing positionally. */
51
+ attachmentsByParam?: Record<string, string[]>;
46
52
  schemaVersion: string | null;
47
53
  mode: RendererMode;
48
54
  }
49
55
  export interface ActionPayload {
50
56
  /** Uploaded file storage keys (file-typed params) — absent when none. */
51
57
  attachment_keys?: string[];
58
+ /** Per-param attribution of the same keys — absent when none. */
59
+ attachments_by_param?: Record<string, string[]>;
52
60
  source: string;
53
61
  action: {
54
62
  id: string | null;
@@ -46,6 +46,12 @@ export interface BuildArgs {
46
46
  * attachment contract: a TOP-LEVEL `attachment_keys` sibling, never part
47
47
  * of raw_values (the form schema doesn't validate file params). */
48
48
  attachmentKeys?: string[];
49
+ /** #629 follow-up — the SAME storage keys, attributed to the file param
50
+ * each was uploaded against (`{param_key: [keys]}`). Additive: consumers
51
+ * that only read the flat `attachment_keys` are unaffected; consumers
52
+ * that must map keys back to parameters (the staff execute adapter) read
53
+ * this instead of guessing positionally. */
54
+ attachmentsByParam?: Record<string, string[]>;
49
55
  schemaVersion: string | null;
50
56
  mode: RendererMode;
51
57
  }
@@ -53,6 +59,8 @@ export interface BuildArgs {
53
59
  export interface ActionPayload {
54
60
  /** Uploaded file storage keys (file-typed params) — absent when none. */
55
61
  attachment_keys?: string[];
62
+ /** Per-param attribution of the same keys — absent when none. */
63
+ attachments_by_param?: Record<string, string[]>;
56
64
  source: string;
57
65
  action: {
58
66
  id: string | null;
@@ -108,7 +116,7 @@ function pickScope(targetConfig: Record<string, unknown>): Record<string, unknow
108
116
  }
109
117
 
110
118
  export function buildActionPayload(args: BuildArgs): ActionPayload {
111
- const { action, placement, targetConfig, sourceSchema, rawValues, schemaVersion, mode, attachmentKeys } = args;
119
+ const { action, placement, targetConfig, sourceSchema, rawValues, schemaVersion, mode, attachmentKeys, attachmentsByParam } = args;
112
120
 
113
121
  const sourceFromSchema = nullableString(sourceSchema.source);
114
122
  const targetModel =
@@ -152,6 +160,9 @@ export function buildActionPayload(args: BuildArgs): ActionPayload {
152
160
  ...(attachmentKeys && attachmentKeys.length > 0
153
161
  ? { attachment_keys: attachmentKeys }
154
162
  : {}),
163
+ ...(attachmentsByParam && Object.keys(attachmentsByParam).length > 0
164
+ ? { attachments_by_param: attachmentsByParam }
165
+ : {}),
155
166
  };
156
167
  }
157
168
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiaiai-pt/design-system",
3
- "version": "0.43.0",
3
+ "version": "0.44.1",
4
4
  "description": "Design system tokens and Svelte components for aiaiai products",
5
5
  "license": "MIT",
6
6
  "type": "module",