@arronqzy/vue-blueprint 0.1.4 → 0.1.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arronqzy/vue-blueprint",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Vue 3 blueprint editor for Abuilder (Vue Flow + Ant Design Vue)",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -19,9 +19,9 @@
19
19
  "ant-design-vue": "^4.2.6",
20
20
  "dayjs": "^1.11.13",
21
21
  "vue": "^3.5.13",
22
- "@arronqzy/blueprint-dsl": "1.0.6",
23
- "@arronqzy/vue-rx-store": "0.1.2",
24
- "@arronqzy/i18n": "0.1.5"
22
+ "@arronqzy/blueprint-dsl": "1.0.8",
23
+ "@arronqzy/i18n": "0.1.7",
24
+ "@arronqzy/vue-rx-store": "0.1.3"
25
25
  },
26
26
  "peerDependencies": {
27
27
  "vue": "^3.4.0"
@@ -31,8 +31,8 @@
31
31
  "eslint": "^8.57.0",
32
32
  "typescript": "^5.5.4",
33
33
  "vue-tsc": "^2.2.0",
34
- "@arronqzy/eslint-config": "0.0.0",
35
- "@arronqzy/typescript-config": "1.0.0"
34
+ "@arronqzy/typescript-config": "1.0.0",
35
+ "@arronqzy/eslint-config": "0.0.0"
36
36
  },
37
37
  "publishConfig": {
38
38
  "access": "public"
@@ -5,8 +5,10 @@ import { Checkbox, Input, Select } from "ant-design-vue";
5
5
  import {
6
6
  LIFECYCLE_NODE_TYPE,
7
7
  PAGE_LIFECYCLE_PHASES,
8
+ VIEW_EVENT_TYPES,
8
9
  type ExecutionTraceEntry,
9
10
  type PageLifecyclePhase,
11
+ type ViewEventType,
10
12
  } from "@arronqzy/blueprint-dsl";
11
13
 
12
14
  import FetchNodeConfigPanel from "./components/FetchNodeConfigPanel.vue";
@@ -17,9 +19,12 @@ import ViewElementMultiSelect from "./components/ViewElementMultiSelect.vue";
17
19
  import ConfigHintIcon from "./components/ConfigHintIcon.vue";
18
20
  import {
19
21
  getLifecyclePhaseLabel,
22
+ getViewEventTypeLabel,
23
+ inspectEventNodeInputs,
20
24
  patchNodeConfigSource,
21
25
  pruneViewElementIds,
22
26
  resolveBlueprintConfigSource,
27
+ resolveNodeEventConfig,
23
28
  resolveViewElementIds,
24
29
  type BlueprintConfigSource,
25
30
  type BlueprintGraphEdge,
@@ -66,6 +71,7 @@ export type BlueprintNodeConfigSidebarProps = {
66
71
  | "jsonConfig"
67
72
  | "logicConfig"
68
73
  | "clockConfig"
74
+ | "eventConfig"
69
75
  >
70
76
  >
71
77
  ) => void;
@@ -82,6 +88,7 @@ const props = withDefaults(defineProps<BlueprintNodeConfigSidebarProps>(), {
82
88
 
83
89
  const configSource = computed(() => resolveBlueprintConfigSource(props.node));
84
90
  const linkedViewElementIds = computed(() => resolveViewElementIds(props.node));
91
+ const selectedEventTypes = computed(() => resolveNodeEventConfig(props.node).eventTypes);
85
92
  const existingViewElementIdSet = computed(
86
93
  () => new Set(props.viewElementOptions.map((opt) => opt.id))
87
94
  );
@@ -97,12 +104,17 @@ const ROLE_LABEL_KEYS: Record<BlueprintNodeRole, string> = {
97
104
  json: "blueprint.config.roleJson",
98
105
  logic: "blueprint.config.roleLogic",
99
106
  clock: "blueprint.config.roleClock",
107
+ event: "blueprint.config.roleEvent",
100
108
  };
101
109
 
102
110
  const roleLabel = computed(() =>
103
111
  t(ROLE_LABEL_KEYS[props.node.role] ?? "blueprint.config.roleLogic")
104
112
  );
105
113
 
114
+ const eventInputs = computed(() =>
115
+ inspectEventNodeInputs(props.node.id, props.graphNodes, props.graphEdges)
116
+ );
117
+
106
118
  watch(
107
119
  [
108
120
  configSource,
@@ -113,7 +125,7 @@ watch(
113
125
  () => props.viewElementOptions.length,
114
126
  ],
115
127
  () => {
116
- if (configSource.value !== "view") return;
128
+ if (configSource.value !== "view" && configSource.value !== "event") return;
117
129
  if (props.viewElementOptions.length === 0) return;
118
130
  const linked = resolveViewElementIds(props.node);
119
131
  if (linked.length === 0) return;
@@ -122,7 +134,7 @@ watch(
122
134
  props.onUpdateNode(props.node.id, {
123
135
  viewElementIds: pruned.length > 0 ? pruned : undefined,
124
136
  viewElementId: undefined,
125
- configSource: "view",
137
+ configSource: configSource.value,
126
138
  });
127
139
  }
128
140
  );
@@ -131,7 +143,7 @@ function handleConfigSourceChange(value: string) {
131
143
  const nextSource = value as BlueprintConfigSource;
132
144
  props.onUpdateNode(props.node.id, {
133
145
  ...patchNodeConfigSource(props.node, nextSource),
134
- ...(nextSource === "view"
146
+ ...(nextSource === "view" || nextSource === "event"
135
147
  ? {
136
148
  viewElementIds: linkedViewElementIds.value,
137
149
  viewElementId: undefined,
@@ -142,6 +154,19 @@ function handleConfigSourceChange(value: string) {
142
154
  }),
143
155
  });
144
156
  }
157
+
158
+ function toggleEventType(eventType: ViewEventType, checked: boolean) {
159
+ const current = selectedEventTypes.value;
160
+ const nextTypes: ViewEventType[] = checked
161
+ ? [...new Set([...current, eventType])]
162
+ : current.filter((type) => type !== eventType);
163
+ props.onUpdateNode(props.node.id, {
164
+ eventConfig: {
165
+ eventTypes: nextTypes.length > 0 ? nextTypes : ["click"],
166
+ },
167
+ configSource: "event",
168
+ });
169
+ }
145
170
  </script>
146
171
 
147
172
  <template>
@@ -177,15 +202,16 @@ function handleConfigSourceChange(value: string) {
177
202
  <Select.Option value="fetch">{{ t("blueprint.config.configFetch") }}</Select.Option>
178
203
  <Select.Option value="json">{{ t("blueprint.config.configJson") }}</Select.Option>
179
204
  <Select.Option value="clock">{{ t("blueprint.config.configClock") }}</Select.Option>
205
+ <Select.Option value="event">{{ t("blueprint.config.configEvent") }}</Select.Option>
180
206
  <Select.Option value="view">{{ t("blueprint.config.configView") }}</Select.Option>
181
207
  </Select>
182
208
  </label>
183
209
 
184
- <div v-if="configSource === 'view'" class="block space-y-1">
210
+ <div v-if="configSource === 'view' || configSource === 'event'" class="block space-y-1">
185
211
  <span class="inline-flex items-center gap-1 text-muted-foreground">
186
212
  {{ t("blueprint.config.linkedViewNodes") }}
187
213
  <ConfigHintIcon :label="t('blueprint.config.linkedViewNodes')">
188
- {{ t("blueprint.config.viewMultiHint") }}
214
+ {{ configSource === 'event' ? t("blueprint.config.eventViewHint") : t("blueprint.config.viewMultiHint") }}
189
215
  </ConfigHintIcon>
190
216
  </span>
191
217
  <ViewElementMultiSelect
@@ -197,7 +223,7 @@ function handleConfigSourceChange(value: string) {
197
223
  onUpdateNode(node.id, {
198
224
  viewElementIds: next,
199
225
  viewElementId: undefined,
200
- configSource: 'view',
226
+ configSource,
201
227
  })
202
228
  "
203
229
  />
@@ -263,6 +289,42 @@ function handleConfigSourceChange(value: string) {
263
289
  </label>
264
290
  </div>
265
291
 
292
+ <div
293
+ v-if="configSource === 'event'"
294
+ class="space-y-2 rounded-md border border-border/70 bg-muted/20 p-2.5"
295
+ >
296
+ <div class="flex items-center gap-1.5">
297
+ <div class="font-medium text-foreground">{{ t("blueprint.config.eventListen") }}</div>
298
+ <ConfigHintIcon :label="t('blueprint.config.eventListen')">
299
+ <p>{{ t("blueprint.config.eventNoInput") }}</p>
300
+ <p>{{ t("blueprint.config.eventHint") }}</p>
301
+ </ConfigHintIcon>
302
+ </div>
303
+ <p
304
+ v-if="eventInputs.nonLifecycleCount > 0"
305
+ class="rounded border border-destructive/40 bg-destructive/10 px-2 py-1.5 text-[11px] leading-relaxed text-destructive"
306
+ >
307
+ {{ t("blueprint.config.eventInputNotLifecycle") }}
308
+ </p>
309
+ <p
310
+ v-else-if="eventInputs.lifecycleCount === 0"
311
+ class="rounded border border-amber-500/40 bg-amber-500/10 px-2 py-1.5 text-[11px] leading-relaxed text-amber-700"
312
+ >
313
+ {{ t("blueprint.config.eventInputMissing") }}
314
+ </p>
315
+ <label
316
+ v-for="eventType in VIEW_EVENT_TYPES"
317
+ :key="eventType"
318
+ class="flex items-center gap-2"
319
+ >
320
+ <Checkbox
321
+ :checked="selectedEventTypes.includes(eventType)"
322
+ @update:checked="(v) => toggleEventType(eventType, Boolean(v))"
323
+ />
324
+ <span>{{ getViewEventTypeLabel(t, eventType) }}</span>
325
+ </label>
326
+ </div>
327
+
266
328
  <div
267
329
  v-if="configSource === 'lifecycle'"
268
330
  class="space-y-2 rounded-md border border-border/70 bg-muted/20 p-2.5"
@@ -6,6 +6,7 @@ import ClockFlowNode from "./nodes/ClockFlowNode.vue";
6
6
  import FetchFlowNode from "./nodes/FetchFlowNode.vue";
7
7
  import JsonFlowNode from "./nodes/JsonFlowNode.vue";
8
8
  import LifecycleFlowNode from "./nodes/LifecycleFlowNode.vue";
9
+ import EventFlowNode from "./nodes/EventFlowNode.vue";
9
10
  import LogicFlowNode from "./nodes/LogicFlowNode.vue";
10
11
 
11
12
  /** 稳定引用,避免 Vue Flow 因 nodeTypes 变化反复卸载节点 */
@@ -17,4 +18,5 @@ export const blueprintNodeTypes: Record<string, Component> = {
17
18
  json: markRaw(JsonFlowNode),
18
19
  logic: markRaw(LogicFlowNode),
19
20
  lifecycle: markRaw(LifecycleFlowNode),
21
+ event: markRaw(EventFlowNode),
20
22
  };
@@ -105,6 +105,7 @@ const {
105
105
  onEdgesChange,
106
106
  onNodeDragStop,
107
107
  onConnect,
108
+ onConnectEnd,
108
109
  isValidConnection,
109
110
  } = flowState;
110
111
 
@@ -201,6 +202,7 @@ function handleDeleteEdge(edgeId: string) {
201
202
  @nodes-change="onNodesChange"
202
203
  @edges-change="onEdgesChange"
203
204
  @connect="onConnect"
205
+ @connect-end="onConnectEnd"
204
206
  :is-valid-connection="isValidConnection"
205
207
  @node-drag-stop="onNodeDragStop"
206
208
  @pane-context-menu="onPaneContextMenu"
@@ -14,7 +14,7 @@ export type BlueprintContextMenuState =
14
14
  clientX: number;
15
15
  clientY: number;
16
16
  nodeId: string;
17
- role: "blueprint" | "logic" | "and" | "lifecycle" | "fetch" | "json" | "clock";
17
+ role: "blueprint" | "logic" | "and" | "lifecycle" | "event" | "fetch" | "json" | "clock";
18
18
  }
19
19
  | {
20
20
  kind: "edge";
@@ -1,16 +1,16 @@
1
1
  <script setup lang="ts">
2
+ import { computed } from "vue";
2
3
  import { useI18n } from "@arronqzy/i18n/vue";
3
4
  import { Tooltip } from "ant-design-vue";
4
5
 
5
6
  const { t } = useI18n();
6
- withDefaults(
7
- defineProps<{
8
- label?: string;
9
- contentClass?: string;
10
- buttonClass?: string;
11
- }>(),
12
- { label: t("common.hint") }
13
- );
7
+ const props = defineProps<{
8
+ label?: string;
9
+ contentClass?: string;
10
+ buttonClass?: string;
11
+ }>();
12
+
13
+ const resolvedLabel = computed(() => props.label || t("common.hint"));
14
14
  </script>
15
15
 
16
16
  <template>
@@ -30,7 +30,7 @@ withDefaults(
30
30
  buttonClass ??
31
31
  'inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-border text-[10px] leading-none text-muted-foreground hover:bg-accent/50'
32
32
  "
33
- :aria-label="t('common.hintAria', { label })"
33
+ :aria-label="t('common.hintAria', { label: resolvedLabel })"
34
34
  >
35
35
  ?
36
36
  </button>
@@ -19,10 +19,13 @@ import {
19
19
  FETCH_REDIRECTS,
20
20
  FETCH_RESPONSE_TYPES,
21
21
  fetchConfigHasScopeTemplate,
22
+ getFetchBodyValidationError,
23
+ getFetchHeadersValidationError,
22
24
  latestTraceOutputsByNode,
23
25
  parseFetchHeadersJson,
24
26
  resolveFetchIncomingScope,
25
27
  resolveFetchRequestUrl,
28
+ resolveFetchScopeAutocompleteRoot,
26
29
  } from "@arronqzy/blueprint-dsl";
27
30
 
28
31
  import type { BlueprintGraphEdge, BlueprintGraphNode } from "../graph/document";
@@ -37,6 +40,7 @@ import {
37
40
  } from "../fetch-config-task-store";
38
41
  import ConfigHintIcon from "./ConfigHintIcon.vue";
39
42
  import FetchUrlAutocomplete from "./FetchUrlAutocomplete.vue";
43
+ import ScopeTemplateAutocompleteHost from "./ScopeTemplateAutocompleteHost.vue";
40
44
 
41
45
  const { t } = useI18n();
42
46
 
@@ -102,6 +106,16 @@ const resolvedHeadersPreview = computed(() => {
102
106
  return "";
103
107
  }
104
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
+ );
105
119
  const incomingHasPendingValue = computed(() => {
106
120
  const scope = incomingScope.value;
107
121
  if (!scope || typeof scope !== "object") return false;
@@ -109,6 +123,10 @@ const incomingHasPendingValue = computed(() => {
109
123
  "kind" in scope ? [scope] : Object.values(scope as Record<string, { kind?: string }>);
110
124
  return entries.some((entry) => entry && entry.kind === "pending");
111
125
  });
126
+ const autocompleteScope = computed(() =>
127
+ resolveFetchScopeAutocompleteRoot(incomingScope.value)
128
+ );
129
+ const formRef = ref<HTMLElement | null>(null);
112
130
  const endpoints = computed(() => fetchConfig.value.swaggerEndpoints ?? []);
113
131
  const hasSwaggerEndpoints = computed(() => endpoints.value.length > 0);
114
132
  const urlInputMode = computed(
@@ -177,6 +195,23 @@ function handleSendFetchDebug() {
177
195
  : t("blueprint.config.fillRequestUrlFirst");
178
196
  return;
179
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
+ });
213
+ return;
214
+ }
180
215
 
181
216
  fetchValidationError.value = null;
182
217
  startFetchDebugTask({
@@ -215,7 +250,7 @@ const debugOk = computed(() => {
215
250
  return result ? result.status >= 200 && result.status < 300 : false;
216
251
  });
217
252
 
218
- function handleHeadersBlur(event: Event) {
253
+ function handleHeadersInput(event: Event) {
219
254
  const headersJson = (event.target as HTMLTextAreaElement).value;
220
255
  const parsed = parseFetchHeadersJson(headersJson);
221
256
  props.onUpdateNode(
@@ -229,7 +264,11 @@ function handleHeadersBlur(event: Event) {
229
264
  </script>
230
265
 
231
266
  <template>
232
- <div class="space-y-2 rounded-md border border-border/70 bg-muted/20 p-2.5">
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" />
233
272
  <div class="flex items-center gap-1.5">
234
273
  <div class="font-medium text-foreground">{{ t("blueprint.config.fetchTitle") }}</div>
235
274
  <ConfigHintIcon :label="t('blueprint.config.fetchTitle')">
@@ -261,6 +300,7 @@ function handleHeadersBlur(event: Event) {
261
300
  :value="fetchConfig.swaggerDocsUrl ?? ''"
262
301
  :disabled="loadingSwagger"
263
302
  placeholder="https://example.com/v3/api-docs"
303
+ data-scope-autocomplete="off"
264
304
  class="flex-1 font-mono text-[11px]"
265
305
  @update:value="
266
306
  (v) =>
@@ -516,13 +556,16 @@ function handleHeadersBlur(event: Event) {
516
556
  </ConfigHintIcon>
517
557
  </span>
518
558
  <textarea
519
- :key="`${node.id}-headers-${headersDraft}`"
520
559
  :value="headersDraft"
521
560
  rows="4"
522
- 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"
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'"
523
563
  placeholder='{"Authorization":"Bearer {scope?.value?.token}"}'
524
- @blur="handleHeadersBlur"
564
+ @input="handleHeadersInput"
525
565
  />
566
+ <p v-if="headersJsonError" class="text-[11px] text-destructive">
567
+ {{ t("blueprint.config.fetchHeadersInvalidJson", { error: headersJsonError }) }}
568
+ </p>
526
569
  <p
527
570
  v-if="usesScopeTemplate && resolvedHeadersPreview"
528
571
  class="whitespace-pre-wrap break-all font-mono text-[10px] text-gray-500"
@@ -532,11 +575,17 @@ function handleHeadersBlur(event: Event) {
532
575
  </label>
533
576
 
534
577
  <label class="block space-y-1">
535
- <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>
536
584
  <textarea
537
585
  :value="fetchConfig.body ?? ''"
538
586
  rows="4"
539
- 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"
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'"
540
589
  placeholder='{"id":"{scope?.value?.id}"}'
541
590
  @input="
542
591
  (e) =>
@@ -546,6 +595,15 @@ function handleHeadersBlur(event: Event) {
546
595
  )
547
596
  "
548
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>
549
607
  </label>
550
608
 
551
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'"
@@ -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>