@aiaiai-pt/design-system 0.43.0 → 0.44.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.
@@ -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,
@@ -490,6 +558,33 @@
490
558
  onchange={(value: string) => setValue(key, value === "true")}
491
559
  aria-required={ariaRequired}
492
560
  />
561
+ {:else if type === "datetime"}
562
+ <!-- `datetime` parameter (#629 PR1b): the DS date+time picker. The value
563
+ bag carries an ISO 8601 string (see datetimeValue). `datetime` is the
564
+ platform's one wire token today (sheets + ActionParam docs). -->
565
+ <DateTimePicker
566
+ label={String(parameter.label ?? key)}
567
+ value={datetimeValue(values[key])}
568
+ readonly={!editable}
569
+ onchange={(date: Date) => setValue(key, date.toISOString())}
570
+ />
571
+ {:else if type === "object_reference"}
572
+ <!-- `object_reference` parameter (#629 PR1b): lazy-search Combobox over
573
+ the entity type named by `object_type`; `object_filter` rides the
574
+ injected searchEntities seam. No seam → disabled (the file-param
575
+ convention for a missing consumer transport). -->
576
+ <Combobox
577
+ label={String(parameter.label ?? key)}
578
+ items={referenceItems[key] ?? []}
579
+ value={String(values[key] ?? "")}
580
+ placeholder={typeof parameter.object_type === "string" && parameter.object_type
581
+ ? `Search ${parameter.object_type}...`
582
+ : "Search..."}
583
+ disabled={!searchEntities || !editable}
584
+ loading={referenceLoading[key] ?? false}
585
+ onsearch={(query: string) => handleReferenceSearch(parameter, query)}
586
+ onchange={(value: string) => setValue(key, value)}
587
+ />
493
588
  {:else if type === "file"}
494
589
  <!-- `file` parameter (#75 M5 slice 4b): upload-as-you-attach. The keys
495
590
  ride payload.attachment_keys; raw_values never sees this param. -->
@@ -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;
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.0",
4
4
  "description": "Design system tokens and Svelte components for aiaiai products",
5
5
  "license": "MIT",
6
6
  "type": "module",