@arronqzy/vue-blueprint 0.1.3 → 0.1.5

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.
@@ -3,11 +3,14 @@ import { useI18n } from "@arronqzy/i18n/vue";
3
3
  import { computed, ref } from "vue";
4
4
  import { Button, Input, Select } from "ant-design-vue";
5
5
  import type {
6
+ ExecutionTraceEntry,
6
7
  FetchHttpMethod,
7
8
  FetchRequestConfig,
8
9
  FetchResponseType,
9
10
  } from "@arronqzy/blueprint-dsl";
10
11
  import {
12
+ applyFetchConfigScope,
13
+ draftFetchHeadersText,
11
14
  FETCH_CACHES,
12
15
  FETCH_CREDENTIALS,
13
16
  FETCH_HTTP_METHODS,
@@ -15,9 +18,17 @@ import {
15
18
  FETCH_NODE_TYPE,
16
19
  FETCH_REDIRECTS,
17
20
  FETCH_RESPONSE_TYPES,
21
+ fetchConfigHasScopeTemplate,
22
+ getFetchBodyValidationError,
23
+ getFetchHeadersValidationError,
24
+ latestTraceOutputsByNode,
25
+ parseFetchHeadersJson,
26
+ resolveFetchIncomingScope,
27
+ resolveFetchRequestUrl,
28
+ resolveFetchScopeAutocompleteRoot,
18
29
  } from "@arronqzy/blueprint-dsl";
19
30
 
20
- import type { BlueprintGraphNode } from "../graph/document";
31
+ import type { BlueprintGraphEdge, BlueprintGraphNode } from "../graph/document";
21
32
  import { resolveNodeFetchConfig } from "../graph/document";
22
33
  import {
23
34
  cancelFetchDebugTask,
@@ -27,25 +38,95 @@ import {
27
38
  useFetchDebugTask,
28
39
  useSwaggerLoadTask,
29
40
  } from "../fetch-config-task-store";
41
+ import ConfigHintIcon from "./ConfigHintIcon.vue";
30
42
  import FetchUrlAutocomplete from "./FetchUrlAutocomplete.vue";
43
+ import ScopeTemplateAutocompleteHost from "./ScopeTemplateAutocompleteHost.vue";
31
44
 
32
45
  const { t } = useI18n();
33
46
 
34
47
  export type FetchNodeConfigPanelProps = {
35
48
  node: BlueprintGraphNode;
49
+ graphNodes?: BlueprintGraphNode[];
50
+ graphEdges?: BlueprintGraphEdge[];
51
+ traceEntries?: ExecutionTraceEntry[];
36
52
  onUpdateNode: (
37
53
  nodeId: string,
38
54
  patch: Partial<Pick<BlueprintGraphNode, "fetchConfig" | "configSource">>
39
55
  ) => void;
40
56
  };
41
57
 
42
- const props = defineProps<FetchNodeConfigPanelProps>();
58
+ const props = withDefaults(defineProps<FetchNodeConfigPanelProps>(), {
59
+ graphNodes: () => [],
60
+ graphEdges: () => [],
61
+ traceEntries: () => [],
62
+ });
43
63
 
44
64
  const validationError = ref<string | null>(null);
45
65
  const fetchValidationError = ref<string | null>(null);
46
66
  const debugExpanded = ref(false);
47
67
 
48
68
  const fetchConfig = computed(() => resolveNodeFetchConfig(props.node));
69
+ const incomingScope = computed(() => {
70
+ const outputs = latestTraceOutputsByNode(props.traceEntries);
71
+ return resolveFetchIncomingScope({
72
+ fetchNodeId: props.node.id,
73
+ nodes: props.graphNodes,
74
+ edges: props.graphEdges,
75
+ getOutput: (sourceId, port) => outputs[sourceId]?.[port],
76
+ });
77
+ });
78
+ const usesScopeTemplate = computed(() => fetchConfigHasScopeTemplate(fetchConfig.value));
79
+ const resolvedFetchConfig = computed(() =>
80
+ applyFetchConfigScope(fetchConfig.value, incomingScope.value)
81
+ );
82
+ const resolvedUrlPreview = computed(() => {
83
+ if (!usesScopeTemplate.value) return "";
84
+ try {
85
+ return resolveFetchRequestUrl(resolvedFetchConfig.value);
86
+ } catch {
87
+ return resolvedFetchConfig.value.url?.trim() ?? "";
88
+ }
89
+ });
90
+ const incomingScopeJson = computed(() => {
91
+ if (incomingScope.value === undefined) return "";
92
+ try {
93
+ return JSON.stringify(incomingScope.value, null, 2);
94
+ } catch {
95
+ return String(incomingScope.value);
96
+ }
97
+ });
98
+ const headersDraft = computed(() => draftFetchHeadersText(fetchConfig.value));
99
+ const resolvedHeadersPreview = computed(() => {
100
+ if (!usesScopeTemplate.value) return "";
101
+ const headers = resolvedFetchConfig.value.headers;
102
+ if (!headers || Object.keys(headers).length === 0) return "";
103
+ try {
104
+ return JSON.stringify(headers, null, 2);
105
+ } catch {
106
+ return "";
107
+ }
108
+ });
109
+ const resolvedBodyPreview = computed(() => {
110
+ if (!usesScopeTemplate.value) return "";
111
+ return resolvedFetchConfig.value.body?.trim() ?? "";
112
+ });
113
+ const headersJsonError = computed(() =>
114
+ getFetchHeadersValidationError(fetchConfig.value, incomingScope.value)
115
+ );
116
+ const bodyJsonError = computed(() =>
117
+ getFetchBodyValidationError(fetchConfig.value.body, resolvedFetchConfig.value.body)
118
+ );
119
+ const incomingHasPendingValue = computed(() => {
120
+ const scope = incomingScope.value;
121
+ if (!scope || typeof scope !== "object") return false;
122
+ const entries =
123
+ "kind" in scope ? [scope] : Object.values(scope as Record<string, { kind?: string }>);
124
+ return entries.some((entry) => entry && entry.kind === "pending");
125
+ });
126
+ const autocompleteScope = computed(() =>
127
+ resolveFetchScopeAutocompleteRoot(incomingScope.value)
128
+ );
129
+ const formRef = ref<HTMLElement | null>(null);
49
130
  const endpoints = computed(() => fetchConfig.value.swaggerEndpoints ?? []);
50
131
  const hasSwaggerEndpoints = computed(() => endpoints.value.length > 0);
51
132
  const urlInputMode = computed(
@@ -105,16 +186,37 @@ function handleAbortSwagger() {
105
186
  }
106
187
 
107
188
  function handleSendFetchDebug() {
108
- const url = fetchConfig.value.url?.trim();
189
+ const resolved = applyFetchConfigScope(fetchConfig.value, incomingScope.value);
190
+ const url = resolved.url?.trim();
109
191
  if (!url) {
110
- fetchValidationError.value = t("blueprint.config.fillRequestUrlFirst");
192
+ fetchValidationError.value =
193
+ usesScopeTemplate.value && fetchConfig.value.url?.trim()
194
+ ? t("blueprint.config.fetchScopeUnresolved")
195
+ : t("blueprint.config.fillRequestUrlFirst");
196
+ return;
197
+ }
198
+ const headersError = getFetchHeadersValidationError(
199
+ fetchConfig.value,
200
+ incomingScope.value
201
+ );
202
+ if (headersError) {
203
+ fetchValidationError.value = t("blueprint.config.fetchHeadersInvalidJson", {
204
+ error: headersError,
205
+ });
206
+ return;
207
+ }
208
+ const bodyError = getFetchBodyValidationError(fetchConfig.value.body, resolved.body);
209
+ if (bodyError) {
210
+ fetchValidationError.value = t("blueprint.config.fetchBodyInvalidJson", {
211
+ error: bodyError,
212
+ });
111
213
  return;
112
214
  }
113
215
 
114
216
  fetchValidationError.value = null;
115
217
  startFetchDebugTask({
116
218
  nodeId: props.node.id,
117
- config: fetchConfig.value,
219
+ config: resolved,
118
220
  });
119
221
  }
120
222
 
@@ -148,25 +250,47 @@ const debugOk = computed(() => {
148
250
  return result ? result.status >= 200 && result.status < 300 : false;
149
251
  });
150
252
 
151
- function handleHeadersBlur(event: Event) {
152
- try {
153
- const headers = JSON.parse((event.target as HTMLTextAreaElement).value || "{}") as Record<
154
- string,
155
- string
156
- >;
157
- props.onUpdateNode(props.node.id, patchFetchConfig(props.node, { headers }));
158
- } catch {
159
- /* 保留上次有效值 */
160
- }
253
+ function handleHeadersInput(event: Event) {
254
+ const headersJson = (event.target as HTMLTextAreaElement).value;
255
+ const parsed = parseFetchHeadersJson(headersJson);
256
+ props.onUpdateNode(
257
+ props.node.id,
258
+ patchFetchConfig(props.node, {
259
+ headersJson,
260
+ ...(parsed ? { headers: parsed } : {}),
261
+ })
262
+ );
161
263
  }
162
264
  </script>
163
265
 
164
266
  <template>
165
- <div class="space-y-2 rounded-md border border-border/70 bg-muted/20 p-2.5">
166
- <div class="font-medium text-foreground">{{ t("blueprint.config.fetchTitle") }}</div>
167
- <p class="text-[11px] text-muted-foreground">
168
- {{ t("blueprint.config.fetchHint") }}
169
- </p>
267
+ <div
268
+ ref="formRef"
269
+ class="space-y-2 rounded-md border border-border/70 bg-muted/20 p-2.5"
270
+ >
271
+ <ScopeTemplateAutocompleteHost :scope="autocompleteScope" :container-ref="formRef" />
272
+ <div class="flex items-center gap-1.5">
273
+ <div class="font-medium text-foreground">{{ t("blueprint.config.fetchTitle") }}</div>
274
+ <ConfigHintIcon :label="t('blueprint.config.fetchTitle')">
275
+ {{ t("blueprint.config.fetchHint") }}
276
+ </ConfigHintIcon>
277
+ </div>
278
+ <div class="space-y-1 rounded-md border border-gray-200/80 bg-white/80 p-2">
279
+ <div class="flex items-center gap-1.5 text-[11px] font-medium text-gray-800">
280
+ {{ t("blueprint.config.fetchScopeTitle") }}
281
+ <ConfigHintIcon :label="t('blueprint.config.fetchScopeTitle')">
282
+ <p>{{ t("blueprint.config.fetchScopeHint") }}</p>
283
+ <p>{{ t("blueprint.config.fetchScopeEmpty") }}</p>
284
+ <p>{{ t("blueprint.config.fetchScopePending") }}</p>
285
+ </ConfigHintIcon>
286
+ </div>
287
+ <template v-if="incomingScope !== undefined">
288
+ <p v-if="incomingHasPendingValue" class="text-[11px] text-amber-700">
289
+ {{ t("blueprint.config.fetchScopePending") }}
290
+ </p>
291
+ <pre class="max-h-[180px] overflow-auto rounded border border-gray-200 bg-gray-50 p-2 font-mono text-[10px] leading-relaxed text-gray-800">{{ incomingScopeJson }}</pre>
292
+ </template>
293
+ </div>
170
294
 
171
295
  <label class="block space-y-1">
172
296
  <span class="text-muted-foreground">{{ t("blueprint.config.swaggerUrlOptional") }}</span>
@@ -176,6 +300,7 @@ function handleHeadersBlur(event: Event) {
176
300
  :value="fetchConfig.swaggerDocsUrl ?? ''"
177
301
  :disabled="loadingSwagger"
178
302
  placeholder="https://example.com/v3/api-docs"
303
+ data-scope-autocomplete="off"
179
304
  class="flex-1 font-mono text-[11px]"
180
305
  @update:value="
181
306
  (v) =>
@@ -254,7 +379,7 @@ function handleHeadersBlur(event: Event) {
254
379
  <Input
255
380
  size="small"
256
381
  :value="fetchConfig.apiBaseUrl ?? ''"
257
- placeholder="https://api.example.com/v1"
382
+ placeholder="https://api.example.com/v1/{scope?.value?.tenant}"
258
383
  class="font-mono text-[11px]"
259
384
  @update:value="
260
385
  (v) => onUpdateNode(node.id, patchFetchConfig(node, { apiBaseUrl: String(v) }))
@@ -313,7 +438,7 @@ function handleHeadersBlur(event: Event) {
313
438
  size="small"
314
439
  :value="fetchConfig.url"
315
440
  :disabled="loadingFetchDebug"
316
- placeholder="https://api.example.com/data"
441
+ placeholder="https://api.example.com/users/{scope?.value?.id}"
317
442
  class="flex-1 font-mono text-[11px]"
318
443
  @update:value="(v) => onUpdateNode(node.id, patchFetchConfig(node, { url: String(v) }))"
319
444
  />
@@ -338,6 +463,12 @@ function handleHeadersBlur(event: Event) {
338
463
  </Button>
339
464
  </div>
340
465
 
466
+ <p
467
+ v-if="usesScopeTemplate && resolvedUrlPreview"
468
+ class="break-all font-mono text-[10px] text-gray-500"
469
+ >
470
+ {{ t("blueprint.config.fetchScopeResolvedUrl") }}: {{ resolvedUrlPreview }}
471
+ </p>
341
472
  <p v-if="loadingFetchDebug" class="text-[11px] text-muted-foreground">
342
473
  {{ t("blueprint.config.sendingDebug") }}
343
474
  </p>
@@ -418,24 +549,44 @@ function handleHeadersBlur(event: Event) {
418
549
  </label>
419
550
 
420
551
  <label class="block space-y-1">
421
- <span class="text-muted-foreground">{{ t("blueprint.config.requestHeadersJson") }}</span>
552
+ <span class="inline-flex items-center gap-1 text-muted-foreground">
553
+ {{ t("blueprint.config.requestHeadersJson") }}
554
+ <ConfigHintIcon :label="t('blueprint.config.requestHeadersJson')">
555
+ {{ t("blueprint.config.fetchHeadersHint") }}
556
+ </ConfigHintIcon>
557
+ </span>
422
558
  <textarea
423
- :key="`${node.id}-headers-${JSON.stringify(fetchConfig.headers)}`"
424
- :value="JSON.stringify(fetchConfig.headers ?? {}, null, 2)"
559
+ :value="headersDraft"
425
560
  rows="4"
426
- class="w-full rounded-md border border-input bg-background px-2 py-1.5 font-mono text-[11px] leading-relaxed text-foreground shadow-sm outline-none focus-visible:ring-1 focus-visible:ring-primary"
427
- placeholder='{"Content-Type":"application/json"}'
428
- @blur="handleHeadersBlur"
561
+ class="w-full rounded-md border bg-background px-2 py-1.5 font-mono text-[11px] leading-relaxed text-foreground shadow-sm outline-none focus-visible:ring-1 focus-visible:ring-primary"
562
+ :class="headersJsonError ? 'border-destructive' : 'border-input'"
563
+ placeholder='{"Authorization":"Bearer {scope?.value?.token}"}'
564
+ @input="handleHeadersInput"
429
565
  />
566
+ <p v-if="headersJsonError" class="text-[11px] text-destructive">
567
+ {{ t("blueprint.config.fetchHeadersInvalidJson", { error: headersJsonError }) }}
568
+ </p>
569
+ <p
570
+ v-if="usesScopeTemplate && resolvedHeadersPreview"
571
+ class="whitespace-pre-wrap break-all font-mono text-[10px] text-gray-500"
572
+ >
573
+ {{ t("blueprint.config.fetchScopeResolvedHeaders") }}: {{ resolvedHeadersPreview }}
574
+ </p>
430
575
  </label>
431
576
 
432
577
  <label class="block space-y-1">
433
- <span class="text-muted-foreground">{{ t("blueprint.config.requestBody") }}</span>
578
+ <span class="inline-flex items-center gap-1 text-muted-foreground">
579
+ {{ t("blueprint.config.requestBody") }}
580
+ <ConfigHintIcon :label="t('blueprint.config.requestBody')">
581
+ {{ t("blueprint.config.fetchBodyHint") }}
582
+ </ConfigHintIcon>
583
+ </span>
434
584
  <textarea
435
585
  :value="fetchConfig.body ?? ''"
436
586
  rows="4"
437
- class="w-full rounded-md border border-input bg-background px-2 py-1.5 font-mono text-[11px] leading-relaxed text-foreground shadow-sm outline-none focus-visible:ring-1 focus-visible:ring-primary"
438
- placeholder='{"key":"value"}'
587
+ class="w-full rounded-md border bg-background px-2 py-1.5 font-mono text-[11px] leading-relaxed text-foreground shadow-sm outline-none focus-visible:ring-1 focus-visible:ring-primary"
588
+ :class="bodyJsonError ? 'border-destructive' : 'border-input'"
589
+ placeholder='{"id":"{scope?.value?.id}"}'
439
590
  @input="
440
591
  (e) =>
441
592
  onUpdateNode(
@@ -444,6 +595,15 @@ function handleHeadersBlur(event: Event) {
444
595
  )
445
596
  "
446
597
  />
598
+ <p v-if="bodyJsonError" class="text-[11px] text-destructive">
599
+ {{ t("blueprint.config.fetchBodyInvalidJson", { error: bodyJsonError }) }}
600
+ </p>
601
+ <p
602
+ v-if="usesScopeTemplate && resolvedBodyPreview"
603
+ class="whitespace-pre-wrap break-all font-mono text-[10px] text-gray-500"
604
+ >
605
+ {{ t("blueprint.config.fetchScopeResolvedBody") }}: {{ resolvedBodyPreview }}
606
+ </p>
447
607
  </label>
448
608
 
449
609
  <div class="grid grid-cols-2 gap-2">
@@ -142,6 +142,7 @@ function handleKeyDown(event: KeyboardEvent) {
142
142
  <input
143
143
  :value="inputValue"
144
144
  :readOnly="selectOnly && !open"
145
+ data-scope-autocomplete="off"
145
146
  :placeholder="placeholder"
146
147
  class="flex h-8 w-full rounded-md border border-input bg-background px-2 py-1 text-[11px] text-foreground shadow-sm outline-none focus-visible:ring-1 focus-visible:ring-primary"
147
148
  :class="selectOnly && !open ? 'cursor-pointer font-medium' : 'font-mono'"
@@ -4,6 +4,7 @@ import { computed, ref } from "vue";
4
4
  import { validateJsonString } from "@arronqzy/blueprint-dsl";
5
5
  import type { JsonNodeConfig } from "@arronqzy/blueprint-dsl";
6
6
 
7
+ import ConfigHintIcon from "./ConfigHintIcon.vue";
7
8
  import type { BlueprintGraphNode } from "../graph/document";
8
9
  import { resolveNodeJsonConfig } from "../graph/document";
9
10
 
@@ -48,8 +49,12 @@ function handleChange(event: Event) {
48
49
 
49
50
  <template>
50
51
  <div class="space-y-2 rounded-md border border-border/70 bg-muted/20 p-2.5">
51
- <div class="font-medium text-foreground">{{ t("blueprint.config.jsonTitle") }}</div>
52
- <p class="text-[11px] text-muted-foreground">{{ t("blueprint.config.jsonHint") }}</p>
52
+ <div class="flex items-center gap-1.5">
53
+ <div class="font-medium text-foreground">{{ t("blueprint.config.jsonTitle") }}</div>
54
+ <ConfigHintIcon :label="t('blueprint.config.jsonTitle')">
55
+ {{ t("blueprint.config.jsonHint") }}
56
+ </ConfigHintIcon>
57
+ </div>
53
58
 
54
59
  <label class="block space-y-1">
55
60
  <span class="text-muted-foreground">{{ t("blueprint.config.jsonContent") }}</span>
@@ -4,6 +4,7 @@ import { computed, ref } from "vue";
4
4
  import { validateLogicSourceCode } from "@arronqzy/blueprint-dsl";
5
5
  import type { LogicNodeConfig } from "@arronqzy/blueprint-dsl";
6
6
 
7
+ import ConfigHintIcon from "./ConfigHintIcon.vue";
7
8
  import type { BlueprintGraphNode } from "../graph/document";
8
9
  import { resolveNodeLogicConfig } from "../graph/document";
9
10
 
@@ -48,8 +49,12 @@ function handleChange(event: Event) {
48
49
 
49
50
  <template>
50
51
  <div class="space-y-2 rounded-md border border-border/70 bg-muted/20 p-2.5">
51
- <div class="font-medium text-foreground">{{ t("blueprint.config.logicTitle") }}</div>
52
- <p class="text-[11px] text-muted-foreground">{{ t("blueprint.config.logicHint") }}</p>
52
+ <div class="flex items-center gap-1.5">
53
+ <div class="font-medium text-foreground">{{ t("blueprint.config.logicTitle") }}</div>
54
+ <ConfigHintIcon :label="t('blueprint.config.logicTitle')">
55
+ {{ t("blueprint.config.logicHint") }}
56
+ </ConfigHintIcon>
57
+ </div>
53
58
 
54
59
  <label class="block space-y-1">
55
60
  <span class="text-muted-foreground">{{ t("blueprint.config.jsCode") }}</span>
@@ -0,0 +1,231 @@
1
+ <script setup lang="ts">
2
+ import {
3
+ toValue,
4
+ watch,
5
+ shallowRef,
6
+ type MaybeRef,
7
+ } from "vue";
8
+ import {
9
+ applyScopeAutocompleteSelection,
10
+ buildScopeExpression,
11
+ getScopeAutocompleteSuggestions,
12
+ parseScopeAutocomplete,
13
+ type ScopeAutocompleteState,
14
+ } from "@arronqzy/blueprint-dsl";
15
+
16
+ type DropdownState = {
17
+ input: HTMLInputElement | HTMLTextAreaElement;
18
+ state: ScopeAutocompleteState;
19
+ suggestions: string[];
20
+ activeIndex: number;
21
+ rect: DOMRect;
22
+ };
23
+
24
+ const props = defineProps<{
25
+ scope: unknown;
26
+ containerRef?: MaybeRef<HTMLElement | null | undefined>;
27
+ }>();
28
+
29
+ const dropdown = shallowRef<DropdownState | null>(null);
30
+
31
+ function isAutocompleteTarget(
32
+ target: EventTarget | null
33
+ ): target is HTMLInputElement | HTMLTextAreaElement {
34
+ if (target instanceof HTMLTextAreaElement) {
35
+ return target.dataset.scopeAutocomplete !== "off";
36
+ }
37
+ if (target instanceof HTMLInputElement) {
38
+ if (target.dataset.scopeAutocomplete === "off") return false;
39
+ const type = target.type;
40
+ return (
41
+ !type ||
42
+ type === "text" ||
43
+ type === "search" ||
44
+ type === "url" ||
45
+ type === "password"
46
+ );
47
+ }
48
+ return false;
49
+ }
50
+
51
+ function setNativeInputValue(
52
+ element: HTMLInputElement | HTMLTextAreaElement,
53
+ value: string
54
+ ) {
55
+ const proto =
56
+ element instanceof HTMLTextAreaElement
57
+ ? HTMLTextAreaElement.prototype
58
+ : HTMLInputElement.prototype;
59
+ const descriptor = Object.getOwnPropertyDescriptor(proto, "value");
60
+ descriptor?.set?.call(element, value);
61
+ element.dispatchEvent(new Event("input", { bubbles: true }));
62
+ }
63
+
64
+ function closeDropdown() {
65
+ dropdown.value = null;
66
+ }
67
+
68
+ function refreshForInput(input: HTMLInputElement | HTMLTextAreaElement) {
69
+ if (props.scope === undefined) {
70
+ closeDropdown();
71
+ return;
72
+ }
73
+ const cursor = input.selectionStart ?? input.value.length;
74
+ const parsed = parseScopeAutocomplete(input.value, cursor, props.scope);
75
+ if (!parsed) {
76
+ closeDropdown();
77
+ return;
78
+ }
79
+ const suggestions = getScopeAutocompleteSuggestions(props.scope, parsed);
80
+ if (suggestions.length === 0) {
81
+ closeDropdown();
82
+ return;
83
+ }
84
+ dropdown.value = {
85
+ input,
86
+ state: parsed,
87
+ suggestions,
88
+ activeIndex: 0,
89
+ rect: input.getBoundingClientRect(),
90
+ };
91
+ }
92
+
93
+ function applySuggestion(index: number) {
94
+ const current = dropdown.value;
95
+ if (!current) return;
96
+ const selected = current.suggestions[index];
97
+ if (!selected) return;
98
+
99
+ const { value, cursor } = applyScopeAutocompleteSelection(
100
+ current.input.value,
101
+ current.state,
102
+ selected
103
+ );
104
+ setNativeInputValue(current.input, value);
105
+ current.input.focus();
106
+ current.input.setSelectionRange(cursor, cursor);
107
+ closeDropdown();
108
+ window.requestAnimationFrame(() => {
109
+ refreshForInput(current.input);
110
+ });
111
+ }
112
+
113
+ function onFocusIn(event: FocusEvent) {
114
+ if (!isAutocompleteTarget(event.target)) return;
115
+ refreshForInput(event.target);
116
+ }
117
+
118
+ function onInput(event: Event) {
119
+ if (!isAutocompleteTarget(event.target)) return;
120
+ refreshForInput(event.target);
121
+ }
122
+
123
+ function onKeyDown(event: KeyboardEvent) {
124
+ const current = dropdown.value;
125
+ if (!current || event.target !== current.input) return;
126
+
127
+ if (event.key === "ArrowDown") {
128
+ event.preventDefault();
129
+ dropdown.value = {
130
+ ...current,
131
+ activeIndex: Math.min(current.activeIndex + 1, current.suggestions.length - 1),
132
+ };
133
+ return;
134
+ }
135
+ if (event.key === "ArrowUp") {
136
+ event.preventDefault();
137
+ dropdown.value = {
138
+ ...current,
139
+ activeIndex: Math.max(current.activeIndex - 1, 0),
140
+ };
141
+ return;
142
+ }
143
+ if (event.key === "Enter" || event.key === "Tab") {
144
+ if (current.suggestions.length > 0) {
145
+ event.preventDefault();
146
+ applySuggestion(current.activeIndex);
147
+ }
148
+ return;
149
+ }
150
+ if (event.key === "Escape") {
151
+ event.preventDefault();
152
+ closeDropdown();
153
+ }
154
+ }
155
+
156
+ function onPointerDown(event: MouseEvent) {
157
+ const target = event.target as Node | null;
158
+ if (
159
+ dropdown.value?.input.contains(target ?? null) ||
160
+ (target instanceof Element && target.closest("[data-scope-ac-list]"))
161
+ ) {
162
+ return;
163
+ }
164
+ const container = toValue(props.containerRef);
165
+ if (container && !container.contains(target)) {
166
+ closeDropdown();
167
+ }
168
+ }
169
+
170
+ watch(
171
+ () => toValue(props.containerRef),
172
+ (container, _prev, onCleanup) => {
173
+ if (!container || props.scope === undefined) return;
174
+
175
+ container.addEventListener("focusin", onFocusIn);
176
+ container.addEventListener("input", onInput, true);
177
+ container.addEventListener("keydown", onKeyDown, true);
178
+ window.addEventListener("pointerdown", onPointerDown, true);
179
+ window.addEventListener("scroll", closeDropdown, true);
180
+ window.addEventListener("resize", closeDropdown);
181
+
182
+ onCleanup(() => {
183
+ container.removeEventListener("focusin", onFocusIn);
184
+ container.removeEventListener("input", onInput, true);
185
+ container.removeEventListener("keydown", onKeyDown, true);
186
+ window.removeEventListener("pointerdown", onPointerDown, true);
187
+ window.removeEventListener("scroll", closeDropdown, true);
188
+ window.removeEventListener("resize", closeDropdown);
189
+ });
190
+ },
191
+ { immediate: true }
192
+ );
193
+
194
+ watch(
195
+ () => props.scope,
196
+ () => {
197
+ if (props.scope === undefined) closeDropdown();
198
+ }
199
+ );
200
+ </script>
201
+
202
+ <template>
203
+ <Teleport v-if="dropdown && scope !== undefined" to="body">
204
+ <div
205
+ data-scope-ac-list
206
+ class="fixed z-[10120] max-h-44 min-w-[140px] overflow-auto rounded-md border border-border bg-white py-1 text-gray-800 shadow-md"
207
+ :style="{
208
+ left: `${dropdown.rect.left}px`,
209
+ top: `${dropdown.rect.bottom + 4}px`,
210
+ width: `${Math.max(dropdown.rect.width, 160)}px`,
211
+ }"
212
+ >
213
+ <button
214
+ v-for="(key, index) in dropdown.suggestions"
215
+ :key="key"
216
+ type="button"
217
+ :class="[
218
+ 'flex w-full flex-col items-start gap-0.5 px-2 py-1.5 text-left text-[11px] hover:bg-gray-100',
219
+ index === dropdown.activeIndex && 'bg-gray-100',
220
+ ]"
221
+ @mousedown.prevent
222
+ @click="applySuggestion(index)"
223
+ >
224
+ <span class="font-medium text-gray-800">{{ key }}</span>
225
+ <span class="font-mono text-[10px] text-gray-500">{{
226
+ `{${buildScopeExpression(dropdown.state.path, key)}}`
227
+ }}</span>
228
+ </button>
229
+ </div>
230
+ </Teleport>
231
+ </template>