@antglobal/copilot-cards-web 1.0.2 → 1.0.3

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.
Files changed (3) hide show
  1. package/dist/index.d.ts +438 -12
  2. package/dist/index.js +3623 -291
  3. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { getBuiltinIcon, normalizeSchema, validateSchema, parseSchema, createLifecycleManager, hasExpression, resolveExpression, resolveDeep, resolveExpressionValue, resolveActionRef, runActionSteps, StreamingParser, StreamingEngine, extractPartialSchema, runActionStep, registry } from '@antglobal/copilot-cards-core';
1
+ import { getBuiltinIcon, cloneJsonData, createLifecycleManager, materializeCard, resolveActionRef, createA2UIParameterResolver, createExpressionContext, resolveA2UIDeep, hasExpression, resolveExpression, resolveDeep, resolveExpressionValue, replaceRootContents, isBoundRenderTreeNode, runActionSteps, normalizeSchema, validateSchema, requiresBindingMaterialization, parseSchema, StreamingParser, StreamingEngine, findAffectedRepeatOwners, bindingTopologyFingerprint, findTemplateRepeatOwners, extractPartialSchema, runActionStep, registry } from '@antglobal/copilot-cards-core';
2
2
  export { ActionRegistry, a2uiComponentToElement, a2uiToCommand, convertLegacySchema, createLifecycleManager, hasExpression, isA2UIEnvelope, isLegacySchema, normalizeSchema, parseSchema, registerActionHandler, registry, resolveActionRef, resolveDeep, resolveExpression, resolveExpressionValue, runActionStep, runActionSteps, validateSchema } from '@antglobal/copilot-cards-core';
3
3
  import * as echarts from 'echarts/core';
4
4
  import { LineChart, BarChart, PieChart, ScatterChart, FunnelChart, HeatmapChart } from 'echarts/charts';
@@ -117,6 +117,57 @@ echarts.use([
117
117
  // ─── Entry ──────────────────────────────────────────────────────
118
118
  /** Attribute marking a DOM node that hosts an ECharts instance. */
119
119
  const CHART_ROOT_ATTR = 'data-echarts-root';
120
+ const chartResizeRegistrations = new WeakMap();
121
+ function toChartCSSSize(value, fallback) {
122
+ if (typeof value === 'number' && Number.isFinite(value)) {
123
+ return `${value}px`;
124
+ }
125
+ if (typeof value === 'string' && value.trim()) {
126
+ return value;
127
+ }
128
+ return `${fallback}px`;
129
+ }
130
+ function disconnectChartResize(chartRoot) {
131
+ const registration = chartResizeRegistrations.get(chartRoot);
132
+ if (!registration)
133
+ return;
134
+ registration.observer.disconnect();
135
+ if (registration.frameID !== null) {
136
+ cancelAnimationFrame(registration.frameID);
137
+ }
138
+ chartResizeRegistrations.delete(chartRoot);
139
+ }
140
+ function observeChartResize(chartRoot, chart) {
141
+ disconnectChartResize(chartRoot);
142
+ if (typeof ResizeObserver === 'undefined')
143
+ return;
144
+ const observer = new ResizeObserver(() => {
145
+ const registration = chartResizeRegistrations.get(chartRoot);
146
+ if (!registration)
147
+ return;
148
+ if (!chartRoot.isConnected || chart.isDisposed?.()) {
149
+ disconnectChartResize(chartRoot);
150
+ return;
151
+ }
152
+ if (registration.frameID !== null) {
153
+ cancelAnimationFrame(registration.frameID);
154
+ }
155
+ registration.frameID = requestAnimationFrame(() => {
156
+ registration.frameID = null;
157
+ if (chartResizeRegistrations.get(chartRoot) === registration &&
158
+ chartRoot.isConnected &&
159
+ !chart.isDisposed?.()) {
160
+ chart.resize();
161
+ }
162
+ });
163
+ });
164
+ const registration = {
165
+ observer,
166
+ frameID: null,
167
+ };
168
+ chartResizeRegistrations.set(chartRoot, registration);
169
+ observer.observe(chartRoot);
170
+ }
120
171
  /**
121
172
  * Dispose every ECharts instance inside `container` and release their global
122
173
  * resize listeners. Call before clearing/detaching a card's DOM (re-render or
@@ -126,6 +177,7 @@ const CHART_ROOT_ATTR = 'data-echarts-root';
126
177
  function disposeChartsIn(container) {
127
178
  const roots = container.querySelectorAll(`[${CHART_ROOT_ATTR}]`);
128
179
  roots.forEach((node) => {
180
+ disconnectChartResize(node);
129
181
  const inst = echarts.getInstanceByDom(node);
130
182
  if (inst)
131
183
  inst.dispose();
@@ -135,10 +187,9 @@ function renderChartSlot(container, slotContent, actionContext) {
135
187
  const config = slotContent?.config;
136
188
  if (!config || !config.type)
137
189
  return;
138
- const width = config.width ?? 340;
139
- const height = config.height ?? 240;
140
190
  const chartDiv = document.createElement('div');
141
- chartDiv.style.cssText = `width:${width}px;height:${height}px`;
191
+ chartDiv.style.width = toChartCSSSize(config.width, 340);
192
+ chartDiv.style.height = toChartCSSSize(config.height, 240);
142
193
  // Marker so disposeChartsIn() can find and tear down the ECharts instance
143
194
  // before its host DOM is detached (otherwise the instance and its global
144
195
  // resize listener leak on every re-render).
@@ -146,6 +197,8 @@ function renderChartSlot(container, slotContent, actionContext) {
146
197
  container.appendChild(chartDiv);
147
198
  // Defer init to next frame to ensure DOM is mounted
148
199
  requestAnimationFrame(() => {
200
+ if (!chartDiv.isConnected)
201
+ return;
149
202
  const chart = echarts.init(chartDiv, undefined, { renderer: 'svg' });
150
203
  let option;
151
204
  switch (config.type) {
@@ -171,6 +224,7 @@ function renderChartSlot(container, slotContent, actionContext) {
171
224
  return;
172
225
  }
173
226
  chart.setOption(option);
227
+ observeChartResize(chartDiv, chart);
174
228
  // Click action
175
229
  if (config.action && actionContext) {
176
230
  chart.on('click', (params) => {
@@ -1447,12 +1501,24 @@ class BaseElement extends HTMLElement {
1447
1501
  const processed = isExpressionResult
1448
1502
  ? style
1449
1503
  : this.resolveSizeInStyle(style);
1450
- return buildStyleString(processed);
1504
+ return this.escapeAttribute(buildStyleString(processed));
1451
1505
  }
1452
1506
  /** Resolve a single size value (number → px). */
1453
1507
  toCSS(value) {
1454
1508
  return resolveSize(value);
1455
1509
  }
1510
+ /**
1511
+ * Escape a value before interpolating it into a double-quoted HTML
1512
+ * attribute. Browsers decode the entities before parsing inline CSS, so
1513
+ * valid CSS strings keep working while quotes cannot create attributes.
1514
+ */
1515
+ escapeAttribute(value) {
1516
+ return value
1517
+ .replace(/&/g, '&')
1518
+ .replace(/"/g, '"')
1519
+ .replace(/</g, '&lt;')
1520
+ .replace(/>/g, '&gt;');
1521
+ }
1456
1522
  resolveSizeInStyle(style) {
1457
1523
  const resolved = {};
1458
1524
  for (const [key, value] of Object.entries(style)) {
@@ -2229,6 +2295,82 @@ if (typeof customElements !== 'undefined' && !customElements.get(CardButton.is))
2229
2295
  customElements.define(CardButton.is, CardButton);
2230
2296
  }
2231
2297
 
2298
+ function escapeAttr$1(value) {
2299
+ return value
2300
+ .replace(/&/g, '&amp;')
2301
+ .replace(/"/g, '&quot;')
2302
+ .replace(/</g, '&lt;')
2303
+ .replace(/>/g, '&gt;');
2304
+ }
2305
+ function escapeText$1(value) {
2306
+ return value
2307
+ .replace(/&/g, '&amp;')
2308
+ .replace(/</g, '&lt;')
2309
+ .replace(/>/g, '&gt;');
2310
+ }
2311
+ function sanitizeImageSrc(src) {
2312
+ const trimmed = String(src ?? '').trim();
2313
+ const scheme = trimmed.replace(/[\s\x00-\x1f]/g, '').toLowerCase();
2314
+ if (/^(javascript|vbscript|file):/.test(scheme))
2315
+ return '';
2316
+ if (/^data:/.test(scheme) && !/^data:image\//.test(scheme))
2317
+ return '';
2318
+ return trimmed;
2319
+ }
2320
+ function renderIconContent(input) {
2321
+ const size = Number(input.size) || 24;
2322
+ const name = input.name == null ? undefined : String(input.name);
2323
+ if (input.src) {
2324
+ return `<img class="icon-img" src="${escapeAttr$1(sanitizeImageSrc(input.src))}" ` +
2325
+ `width="${size}" height="${size}" alt="${escapeAttr$1(name ?? 'icon')}" />`;
2326
+ }
2327
+ const icon = getBuiltinIcon(name);
2328
+ if (icon) {
2329
+ return `<svg class="icon-svg" viewBox="${escapeAttr$1(icon.viewBox)}" width="${size}" ` +
2330
+ `height="${size}" color="${escapeAttr$1(String(input.color ?? 'currentColor'))}" ` +
2331
+ `aria-hidden="true">${icon.body}</svg>`;
2332
+ }
2333
+ return `<span class="icon-text" style="font-size:${size}px; line-height:1;">` +
2334
+ `${escapeText$1(name ?? '?')}</span>`;
2335
+ }
2336
+
2337
+ function escapeText(value) {
2338
+ return String(value)
2339
+ .replace(/&/g, '&amp;')
2340
+ .replace(/</g, '&lt;')
2341
+ .replace(/>/g, '&gt;');
2342
+ }
2343
+ /**
2344
+ * Normalizes the compact Input affix API into safe markup.
2345
+ *
2346
+ * A string is the common shorthand. Objects either render `text`, or reuse
2347
+ * the shared Icon renderer for built-in icons and external images.
2348
+ */
2349
+ function renderInputAffix(affix) {
2350
+ if (affix == null || affix === '')
2351
+ return undefined;
2352
+ if (typeof affix === 'string') {
2353
+ return {
2354
+ content: escapeText(affix),
2355
+ ariaLabel: affix,
2356
+ };
2357
+ }
2358
+ if (affix.text != null) {
2359
+ return {
2360
+ content: escapeText(affix.text),
2361
+ ariaLabel: affix.ariaLabel ?? String(affix.text),
2362
+ style: affix.style,
2363
+ };
2364
+ }
2365
+ if (affix.name == null && affix.src == null)
2366
+ return undefined;
2367
+ return {
2368
+ content: renderIconContent(affix),
2369
+ ariaLabel: affix.ariaLabel,
2370
+ style: affix.style,
2371
+ };
2372
+ }
2373
+
2232
2374
  /**
2233
2375
  * CardInput — Custom Element for rendering input fields in a card.
2234
2376
  *
@@ -2246,12 +2388,11 @@ if (typeof customElements !== 'undefined' && !customElements.get(CardButton.is))
2246
2388
  * {
2247
2389
  * "type": "Input",
2248
2390
  * "props": {
2249
- * "placeholder": "Enter your name",
2250
- * "inputType": "text",
2251
- * "variableKey": "userName",
2252
- * "label": "Name",
2253
- * "maxLength": 100,
2254
- * "disabled": false,
2391
+ * "placeholder": "Phone Number",
2392
+ * "inputType": "tel",
2393
+ * "variableKey": "phone",
2394
+ * "prefix": "+86",
2395
+ * "suffix": { "name": "info", "size": 18 },
2255
2396
  * "style": { "width": 300 }
2256
2397
  * },
2257
2398
  * "events": {
@@ -2260,33 +2401,146 @@ if (typeof customElements !== 'undefined' && !customElements.get(CardButton.is))
2260
2401
  * }
2261
2402
  * ```
2262
2403
  */
2404
+ function getNumberStepperDisabledState(input) {
2405
+ const blocked = input.disabled || input.readOnly;
2406
+ if (blocked) {
2407
+ return { increase: true, decrease: true };
2408
+ }
2409
+ const value = input.valueAsNumber;
2410
+ if (!Number.isFinite(value)) {
2411
+ return { increase: false, decrease: false };
2412
+ }
2413
+ const min = input.min === '' ? undefined : Number(input.min);
2414
+ const max = input.max === '' ? undefined : Number(input.max);
2415
+ return {
2416
+ increase: Number.isFinite(max) && value >= Number(max),
2417
+ decrease: Number.isFinite(min) && value <= Number(min),
2418
+ };
2419
+ }
2420
+ function stepNumberInput(input, direction) {
2421
+ if (input.disabled || input.readOnly)
2422
+ return false;
2423
+ const previousValue = input.value;
2424
+ try {
2425
+ if (direction === 'increase') {
2426
+ input.stepUp();
2427
+ }
2428
+ else {
2429
+ input.stepDown();
2430
+ }
2431
+ }
2432
+ catch {
2433
+ return false;
2434
+ }
2435
+ if (input.value === previousValue)
2436
+ return false;
2437
+ input.dispatchEvent(new Event('input', { bubbles: true }));
2438
+ input.dispatchEvent(new Event('change', { bubbles: true }));
2439
+ return true;
2440
+ }
2441
+ function normalizeNumberAttribute(value, options = {}) {
2442
+ if (value == null || value === '')
2443
+ return undefined;
2444
+ const numeric = Number(value);
2445
+ if (!Number.isFinite(numeric))
2446
+ return undefined;
2447
+ if (options.positive && numeric <= 0)
2448
+ return undefined;
2449
+ return numeric;
2450
+ }
2263
2451
  class CardInput extends BaseElement {
2264
2452
  render() {
2265
2453
  if (!this.shadowRoot || !this._node)
2266
2454
  return;
2267
- const { placeholder = '', inputType = 'text', label, defaultValue = '', disabled = false, readonly: readOnly = false, maxLength, rows, style, isExpressionResultStyle, } = this._props;
2455
+ const { placeholder = '', inputType = 'text', label, defaultValue = '', disabled = false, readonly: readOnly = false, maxLength, rows, min, max, step = 1, controls = true, prefix, suffix, style, inputStyle, isExpressionResultStyle, } = this._props;
2268
2456
  const inlineStyle = this.buildInlineStyle(style, isExpressionResultStyle);
2457
+ const legacyInputStyle = style && typeof style === 'object' && style.resize != null
2458
+ ? { resize: style.resize }
2459
+ : {};
2460
+ const nativeInlineStyle = this.buildInlineStyle({ ...legacyInputStyle, ...inputStyle }, isExpressionResultStyle);
2269
2461
  const isMobile = this._isMobile;
2270
2462
  const isTextarea = inputType === 'textarea';
2463
+ const isNumber = inputType === 'number';
2464
+ const showNumberStepper = isNumber && controls !== false;
2465
+ const normalizedMin = normalizeNumberAttribute(min);
2466
+ const normalizedMax = normalizeNumberAttribute(max);
2467
+ const normalizedStep = normalizeNumberAttribute(step, { positive: true }) ?? 1;
2468
+ const inputId = `${this._domId(this._node.id)}-control`;
2469
+ const describedByIds = [];
2470
+ const prefixAffix = renderInputAffix(prefix);
2471
+ const suffixAffix = renderInputAffix(suffix);
2472
+ const renderAffix = (position, affix) => {
2473
+ if (!affix)
2474
+ return '';
2475
+ const affixId = `${inputId}-${position}`;
2476
+ const affixStyle = this.buildInlineStyle(affix.style, isExpressionResultStyle);
2477
+ const accessibility = affix.ariaLabel
2478
+ ? `id="${affixId}" aria-label="${this.escapeAttr(affix.ariaLabel)}"`
2479
+ : 'aria-hidden="true"';
2480
+ if (affix.ariaLabel)
2481
+ describedByIds.push(affixId);
2482
+ return `<span class="input-affix input-${position}" ${accessibility} style="${affixStyle}">${affix.content}</span>`;
2483
+ };
2484
+ const prefixHtml = renderAffix('prefix', prefixAffix);
2485
+ const suffixHtml = renderAffix('suffix', suffixAffix);
2486
+ const describedBy = describedByIds.length > 0
2487
+ ? `aria-describedby="${describedByIds.join(' ')}"`
2488
+ : '';
2489
+ const numberAttributes = isNumber
2490
+ ? [
2491
+ normalizedMin == null ? '' : `min="${normalizedMin}"`,
2492
+ normalizedMax == null ? '' : `max="${normalizedMax}"`,
2493
+ `step="${normalizedStep}"`,
2494
+ ].filter(Boolean).join(' ')
2495
+ : '';
2496
+ const numberStepperHtml = showNumberStepper
2497
+ ? `<span class="number-stepper">
2498
+ <button
2499
+ class="number-step-button increase"
2500
+ type="button"
2501
+ aria-label="Increase value"
2502
+ ${disabled || readOnly ? 'disabled' : ''}
2503
+ >${renderIconContent({
2504
+ name: 'caret_up',
2505
+ size: 12,
2506
+ color: 'currentColor',
2507
+ })}</button>
2508
+ <button
2509
+ class="number-step-button decrease"
2510
+ type="button"
2511
+ aria-label="Decrease value"
2512
+ ${disabled || readOnly ? 'disabled' : ''}
2513
+ >${renderIconContent({
2514
+ name: 'caret_down',
2515
+ size: 12,
2516
+ color: 'currentColor',
2517
+ })}</button>
2518
+ </span>`
2519
+ : '';
2271
2520
  const inputTag = isTextarea
2272
2521
  ? `<textarea
2522
+ id="${inputId}"
2273
2523
  class="card-input ${isMobile ? 'card-mobile' : 'card-desktop'}"
2274
- placeholder="${this.escapeAttr(placeholder)}"
2524
+ placeholder="${this.escapeAttr(String(placeholder))}"
2525
+ ${describedBy}
2275
2526
  ${disabled ? 'disabled' : ''}
2276
2527
  ${readOnly ? 'readonly' : ''}
2277
- ${maxLength ? `maxlength="${maxLength}"` : ''}
2278
- rows="${rows ?? 3}"
2279
- style="${inlineStyle}"
2528
+ ${maxLength != null ? `maxlength="${Number(maxLength)}"` : ''}
2529
+ rows="${Number(rows ?? 3)}"
2530
+ style="${nativeInlineStyle}"
2280
2531
  >${this.escapeHtml(String(defaultValue))}</textarea>`
2281
2532
  : `<input
2533
+ id="${inputId}"
2282
2534
  class="card-input ${isMobile ? 'card-mobile' : 'card-desktop'}"
2283
- type="${inputType}"
2284
- placeholder="${this.escapeAttr(placeholder)}"
2535
+ type="${this.escapeAttr(String(inputType))}"
2536
+ placeholder="${this.escapeAttr(String(placeholder))}"
2285
2537
  value="${this.escapeAttr(String(defaultValue))}"
2538
+ ${describedBy}
2286
2539
  ${disabled ? 'disabled' : ''}
2287
2540
  ${readOnly ? 'readonly' : ''}
2288
- ${maxLength ? `maxlength="${maxLength}"` : ''}
2289
- style="${inlineStyle}"
2541
+ ${maxLength != null ? `maxlength="${Number(maxLength)}"` : ''}
2542
+ ${numberAttributes}
2543
+ style="${nativeInlineStyle}"
2290
2544
  />`;
2291
2545
  this.shadowRoot.innerHTML = `
2292
2546
  <style>
@@ -2307,36 +2561,132 @@ class CardInput extends BaseElement {
2307
2561
  color: #666;
2308
2562
  line-height: 1.4;
2309
2563
  }
2310
- .card-input {
2311
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
2312
- 'Helvetica Neue', Arial, sans-serif;
2313
- font-size: 14px;
2314
- line-height: 1.5;
2564
+ .input-control {
2565
+ display: flex;
2566
+ align-items: center;
2567
+ gap: 8px;
2568
+ box-sizing: border-box;
2569
+ width: 100%;
2315
2570
  padding: 8px 12px;
2316
2571
  border: 1px solid #d9d9d9;
2317
2572
  border-radius: 6px;
2318
2573
  outline: none;
2319
2574
  color: #333;
2320
2575
  background-color: #fff;
2321
- box-sizing: border-box;
2322
- width: 100%;
2576
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
2577
+ 'Helvetica Neue', Arial, sans-serif;
2578
+ font-size: 14px;
2579
+ line-height: 1.5;
2323
2580
  transition: border-color 0.2s ease, box-shadow 0.2s ease;
2324
2581
  }
2325
- .card-input::placeholder {
2326
- color: #bfbfbf;
2327
- }
2328
- .card-input:focus {
2582
+ .input-control:focus-within {
2329
2583
  border-color: #1677ff;
2330
2584
  box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.15);
2331
2585
  }
2332
- .card-input:disabled {
2586
+ .input-control.disabled {
2333
2587
  background-color: #f5f5f5;
2334
2588
  color: #bfbfbf;
2335
2589
  cursor: not-allowed;
2336
2590
  }
2337
- .card-input:read-only {
2591
+ .input-control.readonly {
2338
2592
  background-color: #fafafa;
2339
2593
  }
2594
+ .input-control.textarea-control {
2595
+ align-items: flex-start;
2596
+ }
2597
+ .input-control.number-control {
2598
+ overflow: hidden;
2599
+ }
2600
+ .card-input {
2601
+ flex: 1;
2602
+ min-width: 0;
2603
+ box-sizing: border-box;
2604
+ width: 100%;
2605
+ padding: 0;
2606
+ border: 0;
2607
+ outline: none;
2608
+ color: inherit;
2609
+ background: transparent;
2610
+ font: inherit;
2611
+ line-height: inherit;
2612
+ }
2613
+ .card-input::placeholder {
2614
+ color: #bfbfbf;
2615
+ }
2616
+ .card-input:disabled {
2617
+ cursor: not-allowed;
2618
+ }
2619
+ input.card-input[type='number'] {
2620
+ -moz-appearance: textfield;
2621
+ appearance: textfield;
2622
+ }
2623
+ input.card-input[type='number']::-webkit-inner-spin-button,
2624
+ input.card-input[type='number']::-webkit-outer-spin-button {
2625
+ margin: 0;
2626
+ -webkit-appearance: none;
2627
+ appearance: none;
2628
+ }
2629
+ .input-affix {
2630
+ display: inline-flex;
2631
+ flex: none;
2632
+ align-items: center;
2633
+ min-width: 0;
2634
+ color: #666;
2635
+ white-space: nowrap;
2636
+ line-height: inherit;
2637
+ }
2638
+ .input-affix .icon-img,
2639
+ .input-affix .icon-svg,
2640
+ .input-affix .icon-text {
2641
+ display: block;
2642
+ }
2643
+ .number-stepper {
2644
+ display: flex;
2645
+ flex: 0 0 32px;
2646
+ align-self: stretch;
2647
+ flex-direction: column;
2648
+ margin: -8px -12px -8px 0;
2649
+ border-left: 1px solid #d9d9d9;
2650
+ }
2651
+ .number-step-button {
2652
+ display: inline-flex;
2653
+ flex: 1 1 50%;
2654
+ align-items: center;
2655
+ justify-content: center;
2656
+ min-width: 0;
2657
+ min-height: 0;
2658
+ margin: 0;
2659
+ padding: 0;
2660
+ border: 0;
2661
+ border-radius: 0;
2662
+ outline: none;
2663
+ color: #8c8c8c;
2664
+ background: transparent;
2665
+ font: inherit;
2666
+ cursor: pointer;
2667
+ transition: color 0.15s ease, background-color 0.15s ease;
2668
+ }
2669
+ .number-step-button + .number-step-button {
2670
+ border-top: 1px solid #d9d9d9;
2671
+ }
2672
+ .number-step-button:hover:not(:disabled) {
2673
+ color: #1677ff;
2674
+ background-color: #e6f4ff;
2675
+ }
2676
+ .number-step-button:active:not(:disabled) {
2677
+ background-color: #bae0ff;
2678
+ }
2679
+ .number-step-button:focus-visible {
2680
+ color: #1677ff;
2681
+ box-shadow: inset 0 0 0 2px rgba(22, 119, 255, 0.35);
2682
+ }
2683
+ .number-step-button:disabled {
2684
+ color: #d9d9d9;
2685
+ cursor: not-allowed;
2686
+ }
2687
+ .number-step-button .icon-svg {
2688
+ display: block;
2689
+ }
2340
2690
 
2341
2691
  /* ─── Textarea ──────────────────────────── */
2342
2692
  textarea.card-input {
@@ -2345,14 +2695,26 @@ class CardInput extends BaseElement {
2345
2695
  }
2346
2696
 
2347
2697
  /* ─── Mobile adjustments ───────────────── */
2348
- .card-input.card-mobile {
2698
+ .input-control.card-mobile {
2349
2699
  font-size: 16px;
2350
2700
  padding: 10px 12px;
2351
2701
  }
2702
+ .input-control.card-mobile .number-stepper {
2703
+ margin-top: -10px;
2704
+ margin-bottom: -10px;
2705
+ }
2352
2706
  </style>
2353
2707
  <div class="card-input-wrapper">
2354
- ${label ? `<label class="card-input-label">${this.escapeHtml(String(label))}</label>` : ''}
2355
- ${inputTag}
2708
+ ${label ? `<label class="card-input-label" for="${inputId}">${this.escapeHtml(String(label))}</label>` : ''}
2709
+ <div
2710
+ class="input-control ${isTextarea ? 'textarea-control' : ''} ${isNumber ? 'number-control' : ''} ${isMobile ? 'card-mobile' : 'card-desktop'} ${disabled ? 'disabled' : ''} ${readOnly ? 'readonly' : ''}"
2711
+ style="${inlineStyle}"
2712
+ >
2713
+ ${prefixHtml}
2714
+ ${inputTag}
2715
+ ${suffixHtml}
2716
+ ${numberStepperHtml}
2717
+ </div>
2356
2718
  </div>
2357
2719
  `;
2358
2720
  // Wire up native input/change events that bubble out of Shadow DOM.
@@ -2376,6 +2738,31 @@ class CardInput extends BaseElement {
2376
2738
  detail: { value: inputEl.value },
2377
2739
  }));
2378
2740
  });
2741
+ if (isNumber &&
2742
+ showNumberStepper &&
2743
+ inputEl instanceof HTMLInputElement) {
2744
+ const increaseButton = this.shadowRoot.querySelector('.number-step-button.increase');
2745
+ const decreaseButton = this.shadowRoot.querySelector('.number-step-button.decrease');
2746
+ const refreshStepperState = () => {
2747
+ const state = getNumberStepperDisabledState(inputEl);
2748
+ if (increaseButton)
2749
+ increaseButton.disabled = state.increase;
2750
+ if (decreaseButton)
2751
+ decreaseButton.disabled = state.decrease;
2752
+ };
2753
+ inputEl.addEventListener('input', refreshStepperState);
2754
+ increaseButton?.addEventListener('click', () => {
2755
+ stepNumberInput(inputEl, 'increase');
2756
+ inputEl.focus();
2757
+ refreshStepperState();
2758
+ });
2759
+ decreaseButton?.addEventListener('click', () => {
2760
+ stepNumberInput(inputEl, 'decrease');
2761
+ inputEl.focus();
2762
+ refreshStepperState();
2763
+ });
2764
+ refreshStepperState();
2765
+ }
2379
2766
  }
2380
2767
  }
2381
2768
  // ─── Helpers ──────────────────────────────────────────────────
@@ -2394,6 +2781,10 @@ class CardInput extends BaseElement {
2394
2781
  .replace(/</g, '&lt;')
2395
2782
  .replace(/>/g, '&gt;');
2396
2783
  }
2784
+ _domId(value) {
2785
+ const normalized = String(value).replace(/[^a-zA-Z0-9_-]/g, '-');
2786
+ return normalized || 'input';
2787
+ }
2397
2788
  }
2398
2789
  CardInput.is = 'ai-card-input';
2399
2790
  // Register the custom element (safe for SSR / non-browser envs)
@@ -2684,6 +3075,25 @@ if (typeof customElements !== 'undefined' && !customElements.get(CardDivider.is)
2684
3075
  customElements.define(CardDivider.is, CardDivider);
2685
3076
  }
2686
3077
 
3078
+ function emitValueChange(host, value, previousValue, source, extra = {}) {
3079
+ const detail = {
3080
+ ...extra,
3081
+ value,
3082
+ previousValue,
3083
+ source,
3084
+ };
3085
+ host.dispatchEvent(new CustomEvent('input', {
3086
+ bubbles: true,
3087
+ composed: true,
3088
+ detail,
3089
+ }));
3090
+ host.dispatchEvent(new CustomEvent('change', {
3091
+ bubbles: true,
3092
+ composed: true,
3093
+ detail,
3094
+ }));
3095
+ }
3096
+
2687
3097
  /**
2688
3098
  * CardRate — Custom Element for star rating display/input.
2689
3099
  *
@@ -2712,29 +3122,59 @@ class CardRate extends BaseElement {
2712
3122
  render() {
2713
3123
  if (!this.shadowRoot || !this._node)
2714
3124
  return;
2715
- const { value = 0, count = 5, readonly: readOnly = false, allowHalf = false, size = 24, gap = 4, color = '#fadb14', inactiveColor = '#e8e8e8', style, isExpressionResultStyle, } = this._props;
3125
+ const { value, defaultValue = 0, count: rawCount = 5, readonly: readOnly = false, disabled = false, allowHalf = false, allowClear = true, size = 24, gap = 4, color = '#fadb14', inactiveColor = '#e8e8e8', ariaLabel = 'Rating', style, starStyle, activeStarStyle, inactiveStarStyle, isExpressionResultStyle, } = this._props;
2716
3126
  const inlineStyle = this.buildInlineStyle(style, isExpressionResultStyle);
2717
- const displayValue = this._currentValue ?? (Number(value) || 0);
3127
+ const count = Math.max(1, Math.min(100, Math.round(Number(rawCount) || 5)));
3128
+ const step = allowHalf ? 0.5 : 1;
3129
+ const externalValue = this.normalizeValue(value ?? defaultValue, count, step);
3130
+ const displayValue = this._currentValue ?? externalValue;
2718
3131
  const starSize = Number(size) || 24;
2719
3132
  const starGap = Number(gap) || 4;
3133
+ const interactive = !disabled && !readOnly;
2720
3134
  const stars = Array.from({ length: count }, (_, i) => {
2721
3135
  const starIndex = i + 1;
2722
3136
  const isFull = displayValue >= starIndex;
2723
3137
  const isHalf = !isFull && allowHalf && displayValue >= starIndex - 0.5;
3138
+ const stateStyle = isFull || isHalf
3139
+ ? activeStarStyle || {}
3140
+ : inactiveStarStyle || {};
3141
+ const itemInline = this.buildInlineStyle({
3142
+ ...(starStyle || {}),
3143
+ ...stateStyle,
3144
+ }, isExpressionResultStyle);
2724
3145
  if (allowHalf) {
3146
+ const wrapperInline = this.buildInlineStyle({
3147
+ fontSize: starSize,
3148
+ position: 'relative',
3149
+ display: 'inline-block',
3150
+ width: '1em',
3151
+ height: '1em',
3152
+ cursor: interactive ? 'pointer' : 'default',
3153
+ });
3154
+ const backgroundInline = this.buildInlineStyle({
3155
+ color: inactiveColor,
3156
+ });
3157
+ const fillInline = this.buildInlineStyle({
3158
+ color,
3159
+ width: isFull ? '100%' : isHalf ? '50%' : '0%',
3160
+ });
2725
3161
  // Two clickable halves per star
2726
3162
  return `
2727
- <span class="star-wrapper" style="font-size:${starSize}px; position:relative; display:inline-block; width:1em; height:1em; cursor:${readOnly ? 'default' : 'pointer'};">
2728
- <span class="star-bg" style="color:${inactiveColor};">★</span>
2729
- <span class="star-fill" style="color:${color}; width:${isFull ? '100%' : isHalf ? '50%' : '0%'};">★</span>
3163
+ <span class="star-wrapper" style="${wrapperInline};${itemInline}">
3164
+ <span class="star-bg" style="${backgroundInline}">★</span>
3165
+ <span class="star-fill" style="${fillInline}">★</span>
2730
3166
  <span class="star-left" data-value="${starIndex - 0.5}"></span>
2731
3167
  <span class="star-right" data-value="${starIndex}"></span>
2732
3168
  </span>`;
2733
3169
  }
3170
+ const starInline = this.buildInlineStyle({
3171
+ fontSize: starSize,
3172
+ color: isFull ? color : inactiveColor,
3173
+ cursor: interactive ? 'pointer' : 'default',
3174
+ });
2734
3175
  // Whole star mode
2735
3176
  return `<span class="star ${isFull ? 'filled' : ''}" data-value="${starIndex}"
2736
- style="font-size:${starSize}px; color:${isFull ? color : inactiveColor};
2737
- cursor:${readOnly ? 'default' : 'pointer'};">★</span>`;
3177
+ style="${starInline};${itemInline}">★</span>`;
2738
3178
  }).join('');
2739
3179
  this.shadowRoot.innerHTML = `
2740
3180
  <style>
@@ -2742,6 +3182,7 @@ class CardRate extends BaseElement {
2742
3182
  display: inline-flex;
2743
3183
  align-items: center;
2744
3184
  box-sizing: border-box;
3185
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
2745
3186
  }
2746
3187
  .rate-wrapper {
2747
3188
  display: inline-flex;
@@ -2780,31 +3221,80 @@ class CardRate extends BaseElement {
2780
3221
  .star-left { left: 0; cursor: pointer; }
2781
3222
  .star-right { right: 0; cursor: pointer; }
2782
3223
  </style>
2783
- <div class="rate-wrapper" style="${inlineStyle}">${stars}</div>
3224
+ <div
3225
+ class="rate-wrapper"
3226
+ role="slider"
3227
+ tabindex="${disabled ? '-1' : '0'}"
3228
+ aria-label="${this.escapeAttr(String(ariaLabel))}"
3229
+ aria-valuemin="0"
3230
+ aria-valuemax="${count}"
3231
+ aria-valuenow="${displayValue}"
3232
+ aria-valuetext="${displayValue} of ${count}"
3233
+ aria-disabled="${Boolean(disabled)}"
3234
+ aria-readonly="${Boolean(readOnly)}"
3235
+ style="${inlineStyle}"
3236
+ >${stars}</div>
2784
3237
  `;
2785
- if (readOnly)
3238
+ if (!interactive)
2786
3239
  return;
2787
3240
  // Bind click handlers
2788
3241
  const clickTargets = this.shadowRoot.querySelectorAll('[data-value]');
2789
3242
  clickTargets.forEach((el) => {
2790
3243
  el.addEventListener('click', () => {
2791
3244
  const val = Number(el.dataset.value);
2792
- // Toggle: click same value to clear
2793
- this._currentValue = (this._currentValue === val) ? 0 : val;
2794
- this.render();
2795
- this.dispatchEvent(new CustomEvent('change', {
2796
- bubbles: true,
2797
- composed: true,
2798
- detail: { value: this._currentValue },
2799
- }));
3245
+ const previousValue = displayValue;
3246
+ const nextValue = allowClear && previousValue === val ? 0 : val;
3247
+ this.commit(nextValue, previousValue, 'pointer', count, step);
2800
3248
  });
2801
3249
  });
3250
+ this.shadowRoot
3251
+ .querySelector('.rate-wrapper')
3252
+ ?.addEventListener('keydown', (event) => {
3253
+ let nextValue = null;
3254
+ if (event.key === 'ArrowRight' || event.key === 'ArrowUp') {
3255
+ nextValue = displayValue + step;
3256
+ }
3257
+ else if (event.key === 'ArrowLeft' || event.key === 'ArrowDown') {
3258
+ nextValue = displayValue - step;
3259
+ }
3260
+ else if (event.key === 'Home') {
3261
+ nextValue = 0;
3262
+ }
3263
+ else if (event.key === 'End') {
3264
+ nextValue = count;
3265
+ }
3266
+ if (nextValue == null)
3267
+ return;
3268
+ event.preventDefault();
3269
+ this.commit(nextValue, displayValue, 'keyboard', count, step);
3270
+ });
2802
3271
  }
2803
3272
  /** Reset internal state when props update externally */
2804
3273
  updateProps(props, isMobile) {
2805
3274
  this._currentValue = null;
2806
3275
  super.updateProps(props, isMobile);
2807
3276
  }
3277
+ commit(next, previous, source, count, step) {
3278
+ const normalized = this.normalizeValue(next, count, step);
3279
+ if (normalized === previous)
3280
+ return;
3281
+ this._currentValue = normalized;
3282
+ this.render();
3283
+ emitValueChange(this, normalized, previous, source);
3284
+ }
3285
+ normalizeValue(value, count, step) {
3286
+ const numeric = Number(value);
3287
+ const safe = Number.isFinite(numeric) ? numeric : 0;
3288
+ const clamped = Math.min(count, Math.max(0, safe));
3289
+ return Math.round(clamped / step) * step;
3290
+ }
3291
+ escapeAttr(value) {
3292
+ return value
3293
+ .replace(/&/g, '&amp;')
3294
+ .replace(/"/g, '&quot;')
3295
+ .replace(/</g, '&lt;')
3296
+ .replace(/>/g, '&gt;');
3297
+ }
2808
3298
  }
2809
3299
  CardRate.is = 'ai-card-rate';
2810
3300
  if (typeof customElements !== 'undefined' && !customElements.get(CardRate.is)) {
@@ -2862,13 +3352,26 @@ class CardCounter extends BaseElement {
2862
3352
  render() {
2863
3353
  if (!this.shadowRoot || !this._node)
2864
3354
  return;
2865
- const { defaultValue, value, min = 0, max = Number.POSITIVE_INFINITY, step = 1, disabled = false, readonly: readOnly = false, size = 28, valueWidth = 32, minusIcon, plusIcon, style, buttonStyle, minusStyle, plusStyle, valueStyle, isExpressionResultStyle, } = this._props;
2866
- const minVal = Number(min);
2867
- const maxVal = Number(max);
2868
- const stepVal = Number(step) || 1;
2869
- const btnSize = Number(size) || 28;
2870
- const numWidth = Number(valueWidth) || 32;
2871
- const initial = this.clamp(Number(defaultValue ?? value ?? minVal) || 0, minVal, maxVal);
3355
+ const { defaultValue, value, min = 0, max = Number.POSITIVE_INFINITY, step = 1, disabled = false, readonly: readOnly = false, size = 28, valueWidth = 32, decreaseAriaLabel = 'decrease', increaseAriaLabel = 'increase', minusIcon, plusIcon, style, buttonStyle, minusStyle, plusStyle, valueStyle, isExpressionResultStyle, } = this._props;
3356
+ const rawMin = Number(min);
3357
+ const rawMax = Number(max);
3358
+ let minVal = Number.isFinite(rawMin) ? rawMin : 0;
3359
+ let maxVal = Number.isNaN(rawMax)
3360
+ ? Number.POSITIVE_INFINITY
3361
+ : rawMax;
3362
+ if (minVal > maxVal) {
3363
+ [minVal, maxVal] = [maxVal, minVal];
3364
+ }
3365
+ const rawStep = Number(step);
3366
+ const stepVal = Number.isFinite(rawStep) && rawStep > 0 ? rawStep : 1;
3367
+ const rawSize = Number(size);
3368
+ const btnSize = Number.isFinite(rawSize) && rawSize > 0 ? rawSize : 28;
3369
+ const rawValueWidth = Number(valueWidth);
3370
+ const numWidth = Number.isFinite(rawValueWidth) && rawValueWidth > 0
3371
+ ? rawValueWidth
3372
+ : 32;
3373
+ const rawInitial = Number(value ?? defaultValue ?? minVal);
3374
+ const initial = this.clamp(Number.isFinite(rawInitial) ? rawInitial : minVal, minVal, maxVal);
2872
3375
  const current = this._currentValue ?? initial;
2873
3376
  const atMin = current <= minVal;
2874
3377
  const atMax = current >= maxVal;
@@ -2944,7 +3447,7 @@ class CardCounter extends BaseElement {
2944
3447
  </style>
2945
3448
  <div class="counter" style="${wrapperStyle}">
2946
3449
  <button type="button" class="c-btn c-minus" part="minus"
2947
- ${minusDisabled ? 'disabled' : ''} aria-label="decrease"
3450
+ ${minusDisabled ? 'disabled' : ''} aria-label="${this.escapeAttr(String(decreaseAriaLabel))}"
2948
3451
  style="${minusInline}">${minusContent}</button>
2949
3452
  <span class="c-value" part="value" role="spinbutton"
2950
3453
  aria-valuenow="${current}"
@@ -2952,7 +3455,7 @@ class CardCounter extends BaseElement {
2952
3455
  ${Number.isFinite(maxVal) ? `aria-valuemax="${maxVal}"` : ''}
2953
3456
  style="${numInline}">${current}</span>
2954
3457
  <button type="button" class="c-btn c-plus" part="plus"
2955
- ${plusDisabled ? 'disabled' : ''} aria-label="increase"
3458
+ ${plusDisabled ? 'disabled' : ''} aria-label="${this.escapeAttr(String(increaseAriaLabel))}"
2956
3459
  style="${plusInline}">${plusContent}</button>
2957
3460
  </div>
2958
3461
  `;
@@ -2960,27 +3463,17 @@ class CardCounter extends BaseElement {
2960
3463
  return;
2961
3464
  const minusBtn = this.shadowRoot.querySelector('.c-minus');
2962
3465
  const plusBtn = this.shadowRoot.querySelector('.c-plus');
2963
- minusBtn?.addEventListener('click', () => this.commit(current - stepVal, minVal, maxVal));
2964
- plusBtn?.addEventListener('click', () => this.commit(current + stepVal, minVal, maxVal));
3466
+ minusBtn?.addEventListener('click', (event) => this.commit(current - stepVal, current, minVal, maxVal, event.detail === 0 ? 'keyboard' : 'pointer'));
3467
+ plusBtn?.addEventListener('click', (event) => this.commit(current + stepVal, current, minVal, maxVal, event.detail === 0 ? 'keyboard' : 'pointer'));
2965
3468
  }
2966
3469
  /** Clamp, de-dup, update internal value and emit input + change. */
2967
- commit(next, min, max) {
3470
+ commit(next, previous, min, max, source) {
2968
3471
  const clamped = this.round(this.clamp(next, min, max));
2969
- if (clamped === this._currentValue)
3472
+ if (clamped === previous)
2970
3473
  return; // no-op at a bound
2971
3474
  this._currentValue = clamped;
2972
3475
  this.render();
2973
- // `input` drives variableKey auto-sync (no re-render); `change` fires actions.
2974
- this.dispatchEvent(new CustomEvent('input', {
2975
- bubbles: true,
2976
- composed: true,
2977
- detail: { value: clamped },
2978
- }));
2979
- this.dispatchEvent(new CustomEvent('change', {
2980
- bubbles: true,
2981
- composed: true,
2982
- detail: { value: clamped },
2983
- }));
3476
+ emitValueChange(this, clamped, previous, source);
2984
3477
  }
2985
3478
  clamp(v, min, max) {
2986
3479
  return Math.min(max, Math.max(min, v));
@@ -3008,11 +3501,18 @@ class CardCounter extends BaseElement {
3008
3501
  */
3009
3502
  renderIcon(icon, fallback, size) {
3010
3503
  if (icon?.src) {
3011
- const safeSrc = this.escapeAttr(this.sanitizeImageSrc(icon.src));
3012
- return `<img src="${safeSrc}" width="${Math.round(size * 0.6)}" height="${Math.round(size * 0.6)}" alt="" />`;
3504
+ return renderIconContent({
3505
+ src: icon.src,
3506
+ name: icon.name,
3507
+ size: Math.round(size * 0.6),
3508
+ });
3509
+ }
3510
+ if (icon?.name) {
3511
+ return renderIconContent({
3512
+ name: icon.name,
3513
+ size: Math.round(size * 0.6),
3514
+ });
3013
3515
  }
3014
- if (icon?.name)
3015
- return this.escapeText(String(icon.name));
3016
3516
  return fallback;
3017
3517
  }
3018
3518
  /** Reset internal state when props update externally. */
@@ -3028,22 +3528,6 @@ class CardCounter extends BaseElement {
3028
3528
  .replace(/</g, '&lt;')
3029
3529
  .replace(/>/g, '&gt;');
3030
3530
  }
3031
- escapeText(str) {
3032
- return str
3033
- .replace(/&/g, '&amp;')
3034
- .replace(/</g, '&lt;')
3035
- .replace(/>/g, '&gt;');
3036
- }
3037
- /** Reject script-bearing / non-image data URIs; pass legit image URLs through. */
3038
- sanitizeImageSrc(src) {
3039
- const trimmed = String(src).trim();
3040
- const scheme = trimmed.replace(/[\s\x00-\x1f]/g, '').toLowerCase();
3041
- if (/^(javascript|vbscript|file):/.test(scheme))
3042
- return '';
3043
- if (/^data:/.test(scheme) && !/^data:image\//.test(scheme))
3044
- return '';
3045
- return trimmed;
3046
- }
3047
3531
  }
3048
3532
  CardCounter.is = 'ai-card-counter';
3049
3533
  if (typeof customElements !== 'undefined' && !customElements.get(CardCounter.is)) {
@@ -3168,28 +3652,94 @@ class CardSelect extends BaseElement {
3168
3652
  constructor() {
3169
3653
  super(...arguments);
3170
3654
  this._open = false;
3655
+ this._activeIndex = -1;
3171
3656
  /** Option picked locally; shown until props.value changes externally. */
3172
3657
  this._localValue = null;
3173
3658
  this._onDocClick = (e) => {
3174
- if (!this.contains(e.target))
3659
+ const includesHost = e.composedPath().includes(this);
3660
+ if (!includesHost)
3175
3661
  this._setOpen(false);
3176
3662
  };
3177
3663
  }
3178
3664
  render() {
3179
3665
  if (!this.shadowRoot || !this._node)
3180
3666
  return;
3181
- const { options = [], placeholder = '请选择', value: propValue = '', disabled = false, style, isExpressionResultStyle, } = this._props;
3667
+ const { options = [], placeholder = '请选择', value: externalValue, defaultValue = '', disabled = false, readonly: readOnly = false, ariaLabel = placeholder, size = 'medium', variant = 'outlined', accentColor = '#1677ff', style, styles = {}, isExpressionResultStyle, } = this._props;
3668
+ const propValue = String(externalValue ?? defaultValue ?? '');
3669
+ const resolvedSize = (['small', 'medium', 'large'].includes(size)
3670
+ ? size
3671
+ : 'medium');
3672
+ const resolvedVariant = (['outlined', 'filled', 'borderless'].includes(variant)
3673
+ ? variant
3674
+ : 'outlined');
3675
+ const semanticStyles = (styles && typeof styles === 'object' ? styles : {});
3676
+ const sizePreset = {
3677
+ small: {
3678
+ triggerHeight: 24,
3679
+ triggerPadding: '0 7px',
3680
+ fontSize: 12,
3681
+ arrowSize: 12,
3682
+ optionFontSize: 12,
3683
+ optionHeight: 24,
3684
+ optionPadding: '1px 8px',
3685
+ },
3686
+ medium: {
3687
+ triggerHeight: 32,
3688
+ triggerPadding: '3px 8px',
3689
+ fontSize: 14,
3690
+ arrowSize: 14,
3691
+ optionFontSize: 14,
3692
+ optionHeight: 32,
3693
+ optionPadding: '5px 12px',
3694
+ },
3695
+ large: {
3696
+ triggerHeight: 40,
3697
+ triggerPadding: '7px 11px',
3698
+ fontSize: 14,
3699
+ arrowSize: 14,
3700
+ optionFontSize: 14,
3701
+ optionHeight: 32,
3702
+ optionPadding: '5px 12px',
3703
+ },
3704
+ }[resolvedSize];
3705
+ const variantPreset = {
3706
+ outlined: {
3707
+ background: '#fff',
3708
+ borderColor: '#d9d9d9',
3709
+ },
3710
+ filled: {
3711
+ background: '#f5f5f5',
3712
+ borderColor: 'transparent',
3713
+ },
3714
+ borderless: {
3715
+ background: 'transparent',
3716
+ borderColor: 'transparent',
3717
+ },
3718
+ }[resolvedVariant];
3182
3719
  // External value change (controlled usage) overrides the local pick
3183
3720
  if (propValue !== this._propValue) {
3184
3721
  this._propValue = propValue;
3185
3722
  this._localValue = null;
3186
3723
  }
3187
3724
  const value = this._localValue ?? propValue;
3188
- const inlineStyle = this.buildInlineStyle(style, isExpressionResultStyle);
3725
+ this.applyHostStyles(style, isExpressionResultStyle, String(accentColor));
3726
+ const triggerInline = this.buildInlineStyle(semanticStyles.trigger, isExpressionResultStyle);
3727
+ const dropdownInline = this.buildInlineStyle(semanticStyles.dropdown, isExpressionResultStyle);
3189
3728
  const selectedOption = options.find((o) => o.value === value);
3190
3729
  const displayText = selectedOption ? selectedOption.label : placeholder;
3191
3730
  const isPlaceholder = !selectedOption;
3192
3731
  const openClass = this._open ? 'open' : '';
3732
+ const arrowIcon = renderIconContent({
3733
+ name: 'caret_down',
3734
+ size: sizePreset.arrowSize,
3735
+ color: 'currentColor',
3736
+ });
3737
+ const listboxId = `${this._node.id}-listbox`;
3738
+ const activeOptionId = (this._open
3739
+ && this._activeIndex >= 0
3740
+ && this._activeIndex < options.length)
3741
+ ? `${this._node.id}-option-${this._activeIndex}`
3742
+ : '';
3193
3743
  this.shadowRoot.innerHTML = `
3194
3744
  <style>
3195
3745
  :host {
@@ -3204,33 +3754,59 @@ class CardSelect extends BaseElement {
3204
3754
  display: flex;
3205
3755
  align-items: center;
3206
3756
  justify-content: space-between;
3207
- padding: 6px 12px;
3208
- border: 1px solid #d9d9d9;
3757
+ box-sizing: border-box;
3758
+ width: 100%;
3759
+ min-height: ${sizePreset.triggerHeight}px;
3760
+ padding: ${sizePreset.triggerPadding};
3761
+ font-size: ${sizePreset.fontSize}px;
3762
+ border: 1px solid ${disabled ? '#e5e7eb' : variantPreset.borderColor};
3209
3763
  border-radius: 6px;
3210
- background: ${disabled ? '#f5f5f5' : '#fff'};
3764
+ outline: none;
3765
+ background: ${disabled ? '#f5f5f5' : variantPreset.background};
3766
+ color: ${disabled ? '#bfbfbf' : isPlaceholder ? '#bfbfbf' : '#333'};
3211
3767
  cursor: ${disabled ? 'not-allowed' : 'pointer'};
3212
- transition: border-color 0.2s;
3768
+ transition:
3769
+ border-color 0.2s,
3770
+ box-shadow 0.2s,
3771
+ background-color 0.2s;
3213
3772
  user-select: none;
3214
3773
  }
3215
3774
  .select-trigger:hover {
3216
- ${!disabled ? 'border-color: #1677ff;' : ''}
3775
+ ${!disabled ? 'border-color: var(--select-accent);' : ''}
3217
3776
  }
3777
+ .select-trigger:focus-visible,
3218
3778
  .select-trigger.open {
3219
- border-color: #1677ff;
3220
- box-shadow: 0 0 0 2px rgba(22,119,255,0.1);
3779
+ border-color: var(--select-accent);
3780
+ box-shadow: 0 0 0 2px rgba(5,145,255,0.1);
3781
+ box-shadow: 0 0 0 2px color-mix(
3782
+ in srgb,
3783
+ var(--select-accent) 10%,
3784
+ transparent
3785
+ );
3221
3786
  }
3222
3787
  .select-text {
3223
3788
  flex: 1;
3789
+ min-width: 0;
3224
3790
  overflow: hidden;
3225
3791
  text-overflow: ellipsis;
3226
3792
  white-space: nowrap;
3227
- color: ${isPlaceholder ? '#bfbfbf' : '#333'};
3793
+ color: inherit;
3228
3794
  }
3229
3795
  .select-arrow {
3796
+ display: inline-flex;
3797
+ flex: none;
3798
+ align-items: center;
3799
+ justify-content: center;
3800
+ width: ${sizePreset.arrowSize}px;
3801
+ height: ${sizePreset.arrowSize}px;
3230
3802
  margin-left: 8px;
3231
- font-size: 10px;
3232
- color: #999;
3803
+ color: ${disabled ? '#bfbfbf' : '#999'};
3233
3804
  transition: transform 0.2s;
3805
+ transform-origin: center;
3806
+ pointer-events: none;
3807
+ }
3808
+ .select-arrow .icon-svg {
3809
+ display: block;
3234
3810
  }
3235
3811
  .select-arrow.open { transform: rotate(180deg); }
3236
3812
  .dropdown {
@@ -3240,63 +3816,242 @@ class CardSelect extends BaseElement {
3240
3816
  left: 0;
3241
3817
  right: 0;
3242
3818
  background: #fff;
3243
- border: 1px solid #e8e8e8;
3244
- border-radius: 6px;
3245
- box-shadow: 0 4px 12px rgba(0,0,0,0.08);
3246
- max-height: 200px;
3819
+ box-sizing: border-box;
3820
+ padding: 4px;
3821
+ border: 0;
3822
+ border-radius: 8px;
3823
+ box-shadow:
3824
+ 0 6px 16px 0 rgba(0,0,0,0.08),
3825
+ 0 3px 6px -4px rgba(0,0,0,0.12),
3826
+ 0 9px 28px 8px rgba(0,0,0,0.05);
3827
+ max-height: 256px;
3247
3828
  overflow-y: auto;
3248
3829
  z-index: 1000;
3249
3830
  }
3250
3831
  .dropdown.open { display: block; }
3251
3832
  .option {
3252
- padding: 6px 12px;
3833
+ display: flex;
3834
+ align-items: center;
3835
+ box-sizing: border-box;
3836
+ min-width: 0;
3837
+ min-height: ${sizePreset.optionHeight}px;
3838
+ padding: ${sizePreset.optionPadding};
3839
+ font-size: ${sizePreset.optionFontSize}px;
3840
+ border-radius: 4px;
3841
+ color: rgba(0,0,0,0.88);
3253
3842
  cursor: pointer;
3254
3843
  transition: background 0.15s;
3844
+ }
3845
+ .option-label {
3846
+ flex: 1;
3847
+ min-width: 0;
3255
3848
  overflow: hidden;
3256
3849
  text-overflow: ellipsis;
3257
3850
  white-space: nowrap;
3258
3851
  }
3259
- .option:hover { background: #f5f5f5; }
3260
- .option.selected { color: #1677ff; font-weight: 500; }
3852
+ .option:hover,
3853
+ .option.active { background: #f5f5f5; }
3854
+ .option.selected {
3855
+ background: #e6f4ff;
3856
+ background: color-mix(
3857
+ in srgb,
3858
+ var(--select-accent) 10%,
3859
+ #fff
3860
+ );
3861
+ color: rgba(0,0,0,0.88);
3862
+ font-weight: 600;
3863
+ }
3864
+ .option.disabled {
3865
+ color: #bfbfbf;
3866
+ cursor: not-allowed;
3867
+ }
3868
+ .option.disabled:hover {
3869
+ background: transparent;
3870
+ }
3261
3871
  </style>
3262
- <div class="select-trigger ${openClass}" style="${inlineStyle}">
3263
- <span class="select-text">${displayText}</span>
3264
- <span class="select-arrow ${openClass}">▼</span>
3872
+ <div
3873
+ class="select-trigger ${openClass}"
3874
+ role="combobox"
3875
+ tabindex="${disabled ? '-1' : '0'}"
3876
+ aria-label="${this.escapeAttr(String(ariaLabel))}"
3877
+ aria-haspopup="listbox"
3878
+ aria-controls="${this.escapeAttr(listboxId)}"
3879
+ ${activeOptionId
3880
+ ? `aria-activedescendant="${this.escapeAttr(activeOptionId)}"`
3881
+ : ''}
3882
+ aria-expanded="${this._open}"
3883
+ aria-disabled="${Boolean(disabled)}"
3884
+ aria-readonly="${Boolean(readOnly)}"
3885
+ style="${triggerInline}"
3886
+ >
3887
+ <span class="select-text">${this.escapeText(String(displayText))}</span>
3888
+ <span class="select-arrow ${openClass}">${arrowIcon}</span>
3265
3889
  </div>
3266
- <div class="dropdown ${openClass}">
3267
- ${options.map((opt) => `<div class="option ${opt.value === value ? 'selected' : ''}" data-value="${opt.value}">${opt.label}</div>`).join('')}
3890
+ <div
3891
+ class="dropdown ${openClass}"
3892
+ id="${this.escapeAttr(listboxId)}"
3893
+ role="listbox"
3894
+ style="${dropdownInline}"
3895
+ >
3896
+ ${options.map((opt, index) => {
3897
+ const optionInline = this.buildInlineStyle({
3898
+ ...(semanticStyles.option || {}),
3899
+ ...(opt.value === value ? semanticStyles.selectedOption || {} : {}),
3900
+ }, isExpressionResultStyle);
3901
+ return (`<div
3902
+ id="${this.escapeAttr(`${this._node.id}-option-${index}`)}"
3903
+ class="option ${opt.value === value ? 'selected' : ''} ${opt.disabled ? 'disabled' : ''} ${index === this._activeIndex ? 'active' : ''}"
3904
+ role="option"
3905
+ aria-selected="${opt.value === value}"
3906
+ aria-disabled="${Boolean(opt.disabled)}"
3907
+ data-index="${index}"
3908
+ data-value="${this.escapeAttr(String(opt.value))}"
3909
+ data-disabled="${opt.disabled ? 'true' : 'false'}"
3910
+ style="${optionInline}"
3911
+ ><span class="option-label">${this.escapeText(String(opt.label))}</span></div>`);
3912
+ }).join('')}
3268
3913
  </div>
3269
3914
  `;
3270
3915
  if (disabled)
3271
3916
  return;
3272
3917
  const trigger = this.shadowRoot.querySelector('.select-trigger');
3273
- trigger.addEventListener('click', () => this._setOpen(!this._open));
3918
+ trigger.addEventListener('click', (event) => {
3919
+ if (readOnly)
3920
+ return;
3921
+ event.stopPropagation();
3922
+ this.openWithCurrentOption(options, value);
3923
+ });
3924
+ trigger.addEventListener('keydown', (event) => {
3925
+ if (readOnly)
3926
+ return;
3927
+ this.handleKeydown(event, options, value);
3928
+ });
3274
3929
  this.shadowRoot.querySelectorAll('.option').forEach((opt) => {
3275
3930
  opt.addEventListener('click', () => {
3931
+ if (opt.dataset.disabled === 'true')
3932
+ return;
3276
3933
  const val = opt.dataset.value ?? '';
3277
- this._localValue = val;
3278
- this._setOpen(false);
3279
- this.dispatchEvent(new CustomEvent('change', {
3280
- bubbles: true,
3281
- composed: true,
3282
- detail: { value: val },
3283
- }));
3934
+ this.commit(val, value, 'pointer');
3284
3935
  });
3285
3936
  });
3286
3937
  }
3287
3938
  disconnectedCallback() {
3288
3939
  document.removeEventListener('click', this._onDocClick);
3289
3940
  }
3941
+ updateProps(props, isMobile) {
3942
+ if (Object.prototype.hasOwnProperty.call(props, 'value')
3943
+ || Object.prototype.hasOwnProperty.call(props, 'defaultValue')) {
3944
+ this._localValue = null;
3945
+ this._propValue = undefined;
3946
+ }
3947
+ super.updateProps(props, isMobile);
3948
+ }
3290
3949
  _setOpen(open) {
3291
3950
  if (this._open === open)
3292
3951
  return;
3293
3952
  this._open = open;
3294
- if (open)
3295
- document.addEventListener('click', this._onDocClick);
3296
- else
3953
+ if (open) {
3954
+ queueMicrotask(() => {
3955
+ if (this._open && this.isConnected) {
3956
+ document.addEventListener('click', this._onDocClick);
3957
+ }
3958
+ });
3959
+ }
3960
+ else {
3297
3961
  document.removeEventListener('click', this._onDocClick);
3962
+ }
3298
3963
  this.render();
3299
3964
  }
3965
+ openWithCurrentOption(options, value) {
3966
+ if (this._open) {
3967
+ this._setOpen(false);
3968
+ return;
3969
+ }
3970
+ const selectedIndex = options.findIndex((option) => !option.disabled && String(option.value) === value);
3971
+ this._activeIndex = selectedIndex >= 0
3972
+ ? selectedIndex
3973
+ : this.findNextEnabled(options, -1, 1);
3974
+ this._setOpen(true);
3975
+ }
3976
+ handleKeydown(event, options, value) {
3977
+ if (event.key === 'Escape') {
3978
+ event.preventDefault();
3979
+ this._setOpen(false);
3980
+ return;
3981
+ }
3982
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
3983
+ event.preventDefault();
3984
+ const direction = event.key === 'ArrowDown' ? 1 : -1;
3985
+ if (!this._open) {
3986
+ const selectedIndex = options.findIndex((option) => !option.disabled && String(option.value) === value);
3987
+ this._activeIndex = this.findNextEnabled(options, selectedIndex, direction);
3988
+ this._setOpen(true);
3989
+ }
3990
+ else {
3991
+ this._activeIndex = this.findNextEnabled(options, this._activeIndex, direction);
3992
+ this.render();
3993
+ }
3994
+ return;
3995
+ }
3996
+ if ((event.key === 'Enter' || event.key === ' ') && this._open) {
3997
+ event.preventDefault();
3998
+ const option = options[this._activeIndex];
3999
+ if (option && !option.disabled) {
4000
+ this.commit(String(option.value), value, 'keyboard');
4001
+ }
4002
+ return;
4003
+ }
4004
+ if (event.key === 'Enter' || event.key === ' ') {
4005
+ event.preventDefault();
4006
+ this.openWithCurrentOption(options, value);
4007
+ }
4008
+ }
4009
+ findNextEnabled(options, start, direction) {
4010
+ if (options.length === 0)
4011
+ return -1;
4012
+ for (let offset = 1; offset <= options.length; offset += 1) {
4013
+ const index = (start + direction * offset + options.length) % options.length;
4014
+ if (!options[index]?.disabled)
4015
+ return index;
4016
+ }
4017
+ return -1;
4018
+ }
4019
+ commit(value, previousValue, source) {
4020
+ if (value === previousValue) {
4021
+ this._setOpen(false);
4022
+ return;
4023
+ }
4024
+ this._localValue = value;
4025
+ this._activeIndex = -1;
4026
+ if (this._open)
4027
+ this._setOpen(false);
4028
+ else
4029
+ this.render();
4030
+ emitValueChange(this, value, previousValue, source);
4031
+ }
4032
+ escapeAttr(value) {
4033
+ return value
4034
+ .replace(/&/g, '&amp;')
4035
+ .replace(/"/g, '&quot;')
4036
+ .replace(/</g, '&lt;')
4037
+ .replace(/>/g, '&gt;');
4038
+ }
4039
+ escapeText(value) {
4040
+ return value
4041
+ .replace(/&/g, '&amp;')
4042
+ .replace(/</g, '&lt;')
4043
+ .replace(/>/g, '&gt;');
4044
+ }
4045
+ applyHostStyles(style, isExpressionResultStyle, accentColor) {
4046
+ const serialized = this.buildInlineStyle(style, isExpressionResultStyle);
4047
+ const parser = document.createElement('div');
4048
+ parser.innerHTML = `<span style="${serialized}"></span>`;
4049
+ const parsedStyle = parser.firstElementChild instanceof HTMLElement
4050
+ ? parser.firstElementChild.style.cssText
4051
+ : '';
4052
+ this.style.cssText = parsedStyle;
4053
+ this.style.setProperty('--select-accent', accentColor || '#1677ff');
4054
+ }
3300
4055
  }
3301
4056
  CardSelect.is = 'ai-card-select';
3302
4057
  if (typeof customElements !== 'undefined' && !customElements.get(CardSelect.is)) {
@@ -3428,45 +4183,6 @@ if (typeof customElements !== 'undefined' && !customElements.get(CardPasscodeInp
3428
4183
  customElements.define(CardPasscodeInput.is, CardPasscodeInput);
3429
4184
  }
3430
4185
 
3431
- function escapeAttr$1(value) {
3432
- return value
3433
- .replace(/&/g, '&amp;')
3434
- .replace(/"/g, '&quot;')
3435
- .replace(/</g, '&lt;')
3436
- .replace(/>/g, '&gt;');
3437
- }
3438
- function escapeText(value) {
3439
- return value
3440
- .replace(/&/g, '&amp;')
3441
- .replace(/</g, '&lt;')
3442
- .replace(/>/g, '&gt;');
3443
- }
3444
- function sanitizeImageSrc(src) {
3445
- const trimmed = String(src ?? '').trim();
3446
- const scheme = trimmed.replace(/[\s\x00-\x1f]/g, '').toLowerCase();
3447
- if (/^(javascript|vbscript|file):/.test(scheme))
3448
- return '';
3449
- if (/^data:/.test(scheme) && !/^data:image\//.test(scheme))
3450
- return '';
3451
- return trimmed;
3452
- }
3453
- function renderIconContent(input) {
3454
- const size = Number(input.size) || 24;
3455
- const name = input.name == null ? undefined : String(input.name);
3456
- if (input.src) {
3457
- return `<img class="icon-img" src="${escapeAttr$1(sanitizeImageSrc(input.src))}" ` +
3458
- `width="${size}" height="${size}" alt="${escapeAttr$1(name ?? 'icon')}" />`;
3459
- }
3460
- const icon = getBuiltinIcon(name);
3461
- if (icon) {
3462
- return `<svg class="icon-svg" viewBox="${escapeAttr$1(icon.viewBox)}" width="${size}" ` +
3463
- `height="${size}" color="${escapeAttr$1(String(input.color ?? 'currentColor'))}" ` +
3464
- `aria-hidden="true">${icon.body}</svg>`;
3465
- }
3466
- return `<span class="icon-text" style="font-size:${size}px; line-height:1;">` +
3467
- `${escapeText(name ?? '?')}</span>`;
3468
- }
3469
-
3470
4186
  /**
3471
4187
  * CardIcon — Custom Element for rendering icons.
3472
4188
  *
@@ -3554,6 +4270,31 @@ if (typeof customElements !== 'undefined' && !customElements.get(CardIcon.is)) {
3554
4270
  * }
3555
4271
  * ```
3556
4272
  */
4273
+ const MAX_VALIDATION_PATTERN_LENGTH = 256;
4274
+ const MAX_PATTERN_INPUT_LENGTH = 4096;
4275
+ /**
4276
+ * Restricts schema-provided patterns to a predictable subset.
4277
+ *
4278
+ * Nested quantified groups, quantified alternations, backreferences, and
4279
+ * lookarounds are rejected because they can introduce catastrophic
4280
+ * backtracking in JavaScript's synchronous RegExp engine.
4281
+ */
4282
+ function isSafeValidationPattern(pattern) {
4283
+ if (pattern.length === 0 || pattern.length > MAX_VALIDATION_PATTERN_LENGTH) {
4284
+ return false;
4285
+ }
4286
+ if (/\\[1-9]/.test(pattern))
4287
+ return false;
4288
+ if (/\(\?(?:[=!]|<[=!])/.test(pattern))
4289
+ return false;
4290
+ if (/\((?:\\.|[^)])*(?:[+*]|\{\d+(?:,\d*)?\})(?:\\.|[^)])*\)(?:[+*]|\{\d+(?:,\d*)?\})/.test(pattern)) {
4291
+ return false;
4292
+ }
4293
+ if (/\((?:\\.|[^)])*\|(?:\\.|[^)])*\)(?:[+*]|\{\d+(?:,\d*)?\})/.test(pattern)) {
4294
+ return false;
4295
+ }
4296
+ return true;
4297
+ }
3557
4298
  class CardForm extends BaseElement {
3558
4299
  constructor() {
3559
4300
  super(...arguments);
@@ -3564,8 +4305,10 @@ class CardForm extends BaseElement {
3564
4305
  render() {
3565
4306
  if (!this.shadowRoot || !this._node)
3566
4307
  return;
3567
- const { fields = [], submitText = '提交', layout = 'vertical', disabled = false, style, isExpressionResultStyle, } = this._props;
4308
+ const { fields = [], submitText = '提交', layout = 'vertical', disabled = false, invalidStyle, errorStyle, style, isExpressionResultStyle, } = this._props;
3568
4309
  const inlineStyle = this.buildInlineStyle(style, isExpressionResultStyle);
4310
+ const invalidInlineStyle = this.buildInlineStyle(invalidStyle, isExpressionResultStyle);
4311
+ const errorInlineStyle = this.buildInlineStyle(errorStyle, isExpressionResultStyle);
3569
4312
  const isHorizontal = layout === 'horizontal';
3570
4313
  // Init default values on first render
3571
4314
  if (!this._submitted && Object.keys(this._values).length === 0) {
@@ -3573,7 +4316,9 @@ class CardForm extends BaseElement {
3573
4316
  this._values[f.name] = f.defaultValue ?? '';
3574
4317
  });
3575
4318
  }
3576
- const fieldItems = fields.map((field) => this._renderField(field)).join('');
4319
+ const fieldItems = fields
4320
+ .map((field, index) => this._renderField(field, index))
4321
+ .join('');
3577
4322
  this.shadowRoot.innerHTML = `
3578
4323
  <style>
3579
4324
  :host {
@@ -3604,7 +4349,12 @@ class CardForm extends BaseElement {
3604
4349
  color: #ff4d4f;
3605
4350
  margin-right: 2px;
3606
4351
  }
3607
- .field-input {
4352
+ .field-control {
4353
+ display: flex;
4354
+ align-items: center;
4355
+ gap: 8px;
4356
+ box-sizing: border-box;
4357
+ width: 100%;
3608
4358
  padding: 6px 12px;
3609
4359
  border: 1px solid #d9d9d9;
3610
4360
  border-radius: 6px;
@@ -3614,35 +4364,73 @@ class CardForm extends BaseElement {
3614
4364
  font-family: inherit;
3615
4365
  background: #fff;
3616
4366
  }
3617
- .field-input:focus {
4367
+ .field-control.textarea-control {
4368
+ align-items: flex-start;
4369
+ }
4370
+ .field-control:focus-within {
3618
4371
  border-color: #1677ff;
3619
4372
  box-shadow: 0 0 0 2px rgba(22,119,255,0.1);
3620
4373
  }
3621
- .field-input.error {
4374
+ .field-control.error {
3622
4375
  border-color: #ff4d4f;
4376
+ ${invalidInlineStyle}
4377
+ }
4378
+ .field-input {
4379
+ flex: 1;
4380
+ min-width: 0;
4381
+ box-sizing: border-box;
4382
+ width: 100%;
4383
+ padding: 0;
4384
+ border: 0;
4385
+ outline: none;
4386
+ color: inherit;
4387
+ background: transparent;
4388
+ font: inherit;
4389
+ line-height: 1.5;
3623
4390
  }
3624
4391
  .field-input:disabled {
3625
- background: #f5f5f5;
3626
4392
  cursor: not-allowed;
3627
4393
  }
4394
+ .field-affix {
4395
+ display: inline-flex;
4396
+ flex: none;
4397
+ align-items: center;
4398
+ color: #666;
4399
+ white-space: nowrap;
4400
+ line-height: 1.5;
4401
+ }
4402
+ .field-affix .icon-img,
4403
+ .field-affix .icon-svg,
4404
+ .field-affix .icon-text {
4405
+ display: block;
4406
+ }
3628
4407
  .field-input-wrapper {
3629
4408
  flex: 1;
3630
4409
  display: flex;
3631
4410
  flex-direction: column;
3632
4411
  min-width: 0;
3633
4412
  }
4413
+ .form-select {
4414
+ display: block;
4415
+ width: 100%;
4416
+ min-width: 0;
4417
+ }
4418
+ .form-rate {
4419
+ display: inline-flex;
4420
+ width: fit-content;
4421
+ }
3634
4422
  textarea.field-input {
3635
4423
  min-height: 60px;
3636
4424
  resize: vertical;
3637
4425
  }
3638
- select.field-input {
3639
- cursor: pointer;
3640
- appearance: auto;
3641
- }
3642
4426
  .field-error {
3643
4427
  font-size: 12px;
3644
4428
  color: #ff4d4f;
3645
- min-height: 18px;
4429
+ line-height: 18px;
4430
+ ${errorInlineStyle}
4431
+ }
4432
+ .field-error:empty {
4433
+ display: none;
3646
4434
  }
3647
4435
  /* Passcode */
3648
4436
  .passcode-row {
@@ -3664,20 +4452,10 @@ class CardForm extends BaseElement {
3664
4452
  border-color: #1677ff;
3665
4453
  box-shadow: 0 0 0 2px rgba(22,119,255,0.1);
3666
4454
  }
3667
- .passcode-cell.error { border-color: #ff4d4f; }
3668
- /* Rate */
3669
- .rate-row {
3670
- display: flex;
3671
- gap: 4px;
3672
- }
3673
- .rate-star {
3674
- font-size: 24px;
3675
- cursor: pointer;
3676
- user-select: none;
3677
- transition: transform 0.15s;
3678
- line-height: 1;
4455
+ .passcode-cell.error {
4456
+ border-color: #ff4d4f;
4457
+ ${invalidInlineStyle}
3679
4458
  }
3680
- .rate-star:hover { transform: scale(1.15); }
3681
4459
  /* Submit */
3682
4460
  .submit-btn {
3683
4461
  padding: 8px 24px;
@@ -3702,71 +4480,268 @@ class CardForm extends BaseElement {
3702
4480
  <button class="submit-btn" ${disabled ? 'disabled' : ''} style="${isHorizontal ? `margin-left: 84px;` : ''}">${submitText}</button>
3703
4481
  </div>
3704
4482
  `;
4483
+ this._initializeSelectFields(fields, disabled);
4484
+ this._initializeRateFields(fields, disabled);
3705
4485
  this._bindEvents(fields, disabled);
3706
4486
  }
3707
- _renderField(field) {
3708
- const { name, label, type = 'text', placeholder = '', required = false, options = [], length = 6 } = field;
4487
+ _renderField(field, index) {
4488
+ const { name, label, type = 'text', placeholder = '', required = false, rules = [], prefix, suffix, options = [], length = 6, } = field;
3709
4489
  const val = this._values[name] ?? '';
3710
4490
  const error = this._errors[name] || '';
3711
4491
  const errorClass = error ? 'error' : '';
3712
- const labelHtml = label ? `<label class="field-label">${required ? '<span class="required">*</span>' : ''}${label}</label>` : '';
4492
+ const escapedName = this._escapeAttr(name);
4493
+ const idBase = `${this._domId(this._node?.id || 'form')}-field-${index}`;
4494
+ const controlId = `${idBase}-control`;
4495
+ const labelId = `${idBase}-label`;
4496
+ const errorId = `${idBase}-error`;
4497
+ const supportsAffixes = type !== 'passcode'
4498
+ && type !== 'rate'
4499
+ && type !== 'select';
4500
+ const prefixAffix = supportsAffixes
4501
+ ? this._renderAffix('prefix', prefix, `${idBase}-prefix`)
4502
+ : undefined;
4503
+ const suffixAffix = supportsAffixes
4504
+ ? this._renderAffix('suffix', suffix, `${idBase}-suffix`)
4505
+ : undefined;
4506
+ const affixDescriptionIds = [
4507
+ prefixAffix?.descriptionId,
4508
+ suffixAffix?.descriptionId,
4509
+ ].filter((id) => Boolean(id));
4510
+ const describedByIds = [
4511
+ ...affixDescriptionIds,
4512
+ ...(error ? [errorId] : []),
4513
+ ];
4514
+ const ariaDescription = describedByIds.length > 0
4515
+ ? `aria-describedby="${describedByIds.join(' ')}"`
4516
+ : '';
4517
+ const affixDescriptionData = affixDescriptionIds.length > 0
4518
+ ? `data-affix-describedby="${affixDescriptionIds.join(' ')}"`
4519
+ : '';
4520
+ const ariaError = `aria-invalid="${Boolean(error)}" ${ariaDescription} ${affixDescriptionData}`;
4521
+ const ariaLabel = label ? `aria-labelledby="${labelId}"` : '';
4522
+ const isRequired = required || rules.some((rule) => rule.required);
4523
+ const labelHtml = label
4524
+ ? `<label class="field-label" id="${labelId}" for="${type === 'passcode' ? `${controlId}-0` : controlId}">${isRequired ? '<span class="required">*</span>' : ''}${this._escapeHtml(label)}</label>`
4525
+ : '';
4526
+ const prefixHtml = prefixAffix?.html ?? '';
4527
+ const suffixHtml = suffixAffix?.html ?? '';
3713
4528
  let inputHtml = '';
4529
+ let usesFieldControl = false;
3714
4530
  switch (type) {
3715
4531
  case 'textarea':
3716
- inputHtml = `<textarea class="field-input ${errorClass}" data-name="${name}" placeholder="${placeholder}">${val}</textarea>`;
4532
+ usesFieldControl = true;
4533
+ inputHtml = `<textarea id="${controlId}" class="field-input" data-name="${escapedName}" placeholder="${this._escapeAttr(placeholder)}" ${ariaLabel} ${ariaError}>${this._escapeHtml(String(val))}</textarea>`;
3717
4534
  break;
3718
4535
  case 'select':
3719
- inputHtml = `<select class="field-input ${errorClass}" data-name="${name}">
3720
- <option value="" ${!val ? 'selected' : ''}>${placeholder || '请选择'}</option>
3721
- ${options.map((o) => `<option value="${o.value}" ${val === o.value ? 'selected' : ''}>${o.label}</option>`).join('')}
3722
- </select>`;
4536
+ inputHtml = `<${CardSelect.is}
4537
+ id="${controlId}"
4538
+ class="form-select"
4539
+ data-name="${escapedName}"
4540
+ ${ariaError}
4541
+ ></${CardSelect.is}>`;
3723
4542
  break;
3724
4543
  case 'passcode': {
3725
4544
  const cells = Array.from({ length }, (_, i) => {
3726
4545
  const cellVal = typeof val === 'string' ? (val[i] || '') : '';
3727
- return `<input class="passcode-cell ${errorClass}" type="text" inputmode="numeric" maxlength="1" data-name="${name}" data-index="${i}" value="${cellVal}" />`;
4546
+ return `<input id="${controlId}-${i}" class="passcode-cell ${errorClass}" type="text" inputmode="numeric" maxlength="1" data-name="${escapedName}" data-index="${i}" value="${this._escapeAttr(cellVal)}" ${ariaLabel} ${ariaError} />`;
3728
4547
  }).join('');
3729
- inputHtml = `<div class="passcode-row" data-passcode-name="${name}">${cells}</div>`;
4548
+ inputHtml = `<div class="passcode-row" data-passcode-name="${escapedName}">${cells}</div>`;
3730
4549
  break;
3731
4550
  }
3732
4551
  case 'rate': {
3733
- const count = 5;
3734
- const rateVal = Number(val) || 0;
3735
- const stars = Array.from({ length: count }, (_, i) => `<span class="rate-star" data-name="${name}" data-value="${i + 1}" style="color:${i < rateVal ? '#fadb14' : '#e8e8e8'};">★</span>`).join('');
3736
- inputHtml = `<div class="rate-row">${stars}</div>`;
4552
+ inputHtml = `<${CardRate.is}
4553
+ id="${controlId}"
4554
+ class="form-rate"
4555
+ data-name="${escapedName}"
4556
+ ${ariaError}
4557
+ ></${CardRate.is}>`;
3737
4558
  break;
3738
4559
  }
3739
- default:
3740
- inputHtml = `<input class="field-input ${errorClass}" type="${type === 'password' ? 'password' : 'text'}" data-name="${name}" placeholder="${placeholder}" value="${val}" />`;
4560
+ default: {
4561
+ usesFieldControl = true;
4562
+ const nativeType = type === 'password' || type === 'number'
4563
+ ? type
4564
+ : 'text';
4565
+ inputHtml = `<input id="${controlId}" class="field-input" type="${nativeType}" data-name="${escapedName}" placeholder="${this._escapeAttr(placeholder)}" value="${this._escapeAttr(String(val))}" ${ariaLabel} ${ariaError} />`;
4566
+ }
4567
+ }
4568
+ if (usesFieldControl) {
4569
+ inputHtml = `
4570
+ <div class="field-control ${type === 'textarea' ? 'textarea-control' : ''} ${errorClass}">
4571
+ ${prefixHtml}
4572
+ ${inputHtml}
4573
+ ${suffixHtml}
4574
+ </div>`;
3741
4575
  }
3742
4576
  return `
3743
4577
  <div class="form-field">
3744
4578
  ${labelHtml}
3745
4579
  <div class="field-input-wrapper">
3746
4580
  ${inputHtml}
3747
- <div class="field-error">${error}</div>
4581
+ <div class="field-error" id="${errorId}" role="alert">${this._escapeHtml(error)}</div>
3748
4582
  </div>
3749
4583
  </div>`;
3750
4584
  }
4585
+ _initializeSelectFields(fields, disabled) {
4586
+ if (!this.shadowRoot)
4587
+ return;
4588
+ this.shadowRoot.querySelectorAll('.form-select').forEach((selectElement) => {
4589
+ const name = selectElement.dataset.name;
4590
+ const field = fields.find((item) => item.name === name);
4591
+ if (!field)
4592
+ return;
4593
+ const props = this._selectProps(field, this._errors[field.name] || '', disabled);
4594
+ const node = {
4595
+ id: selectElement.id,
4596
+ type: 'Select',
4597
+ props,
4598
+ children: [],
4599
+ lifecycle: undefined,
4600
+ events: undefined,
4601
+ directives: undefined,
4602
+ };
4603
+ selectElement.setData(node, props, this._isMobile);
4604
+ const label = selectElement
4605
+ .closest('.form-field')
4606
+ ?.querySelector('.field-label');
4607
+ label?.addEventListener('click', () => {
4608
+ selectElement.shadowRoot
4609
+ ?.querySelector('.select-trigger')
4610
+ ?.focus();
4611
+ });
4612
+ });
4613
+ }
4614
+ _selectProps(field, error, disabled) {
4615
+ return {
4616
+ options: field.options ?? [],
4617
+ placeholder: field.placeholder || '请选择',
4618
+ value: String(this._values[field.name] ?? ''),
4619
+ disabled,
4620
+ ariaLabel: field.label || field.placeholder || '请选择',
4621
+ size: field.size,
4622
+ variant: field.variant,
4623
+ accentColor: field.accentColor,
4624
+ style: {
4625
+ width: '100%',
4626
+ },
4627
+ styles: {
4628
+ ...(field.styles ?? {}),
4629
+ trigger: {
4630
+ ...(field.style ?? {}),
4631
+ ...(field.styles?.trigger ?? {}),
4632
+ ...(error ? this._props.invalidStyle ?? {} : {}),
4633
+ },
4634
+ },
4635
+ isExpressionResultStyle: this._props.isExpressionResultStyle,
4636
+ };
4637
+ }
4638
+ _initializeRateFields(fields, disabled) {
4639
+ if (!this.shadowRoot)
4640
+ return;
4641
+ this.shadowRoot.querySelectorAll('.form-rate').forEach((rateElement) => {
4642
+ const name = rateElement.dataset.name;
4643
+ const field = fields.find((item) => item.name === name);
4644
+ if (!field)
4645
+ return;
4646
+ const props = this._rateProps(field, this._errors[field.name] || '', disabled);
4647
+ const node = {
4648
+ id: rateElement.id,
4649
+ type: 'Rate',
4650
+ props,
4651
+ children: [],
4652
+ lifecycle: undefined,
4653
+ events: undefined,
4654
+ directives: undefined,
4655
+ };
4656
+ rateElement.setData(node, props, this._isMobile);
4657
+ const label = rateElement
4658
+ .closest('.form-field')
4659
+ ?.querySelector('.field-label');
4660
+ label?.addEventListener('click', () => {
4661
+ rateElement.shadowRoot
4662
+ ?.querySelector('.rate-wrapper')
4663
+ ?.focus();
4664
+ });
4665
+ });
4666
+ }
4667
+ _rateProps(field, error, disabled) {
4668
+ return {
4669
+ value: Number(this._values[field.name]) || 0,
4670
+ disabled,
4671
+ ariaLabel: field.label || 'Rating',
4672
+ style: {
4673
+ padding: '3px',
4674
+ border: '1px solid transparent',
4675
+ borderRadius: '6px',
4676
+ ...(field.style ?? {}),
4677
+ ...(error ? this._props.invalidStyle ?? {} : {}),
4678
+ },
4679
+ isExpressionResultStyle: this._props.isExpressionResultStyle,
4680
+ };
4681
+ }
4682
+ _renderAffix(position, affix, id) {
4683
+ const rendered = renderInputAffix(affix);
4684
+ if (!rendered)
4685
+ return undefined;
4686
+ const style = this.buildInlineStyle(rendered.style, this._props.isExpressionResultStyle);
4687
+ const accessibility = rendered.ariaLabel
4688
+ ? `id="${id}" aria-label="${this._escapeAttr(rendered.ariaLabel)}"`
4689
+ : 'aria-hidden="true"';
4690
+ return {
4691
+ html: `<span class="field-affix field-${position}" ${accessibility} style="${style}">${rendered.content}</span>`,
4692
+ descriptionId: rendered.ariaLabel ? id : undefined,
4693
+ };
4694
+ }
3751
4695
  _bindEvents(fields, disabled) {
3752
4696
  if (!this.shadowRoot || disabled)
3753
4697
  return;
3754
- // Text / textarea / select
4698
+ // Text / textarea
3755
4699
  this.shadowRoot.querySelectorAll('.field-input').forEach((el) => {
3756
4700
  const handler = () => {
3757
4701
  const name = el.dataset.name;
3758
4702
  this._values[name] = el.value;
3759
- if (this._errors[name]) {
3760
- this._errors[name] = '';
3761
- const errEl = el.closest('.form-field')?.querySelector('.field-error');
3762
- if (errEl)
3763
- errEl.textContent = '';
3764
- el.classList.remove('error');
3765
- }
4703
+ if (!this._submitted)
4704
+ return;
4705
+ const field = fields.find((item) => item.name === name);
4706
+ if (!field)
4707
+ return;
4708
+ this._updateFieldValidation(field, this._validateField(field));
3766
4709
  };
3767
4710
  el.addEventListener('input', handler);
3768
4711
  el.addEventListener('change', handler);
3769
4712
  });
4713
+ // Select
4714
+ this.shadowRoot.querySelectorAll('.form-select').forEach((selectElement) => {
4715
+ selectElement.addEventListener('change', (event) => {
4716
+ const name = selectElement.dataset.name;
4717
+ const value = event instanceof CustomEvent
4718
+ ? event.detail?.value
4719
+ : undefined;
4720
+ this._values[name] = value ?? '';
4721
+ if (!this._submitted)
4722
+ return;
4723
+ const field = fields.find((item) => item.name === name);
4724
+ if (!field)
4725
+ return;
4726
+ this._updateFieldValidation(field, this._validateField(field));
4727
+ });
4728
+ });
4729
+ // Rate
4730
+ this.shadowRoot.querySelectorAll('.form-rate').forEach((rateElement) => {
4731
+ rateElement.addEventListener('change', (event) => {
4732
+ const name = rateElement.dataset.name;
4733
+ const value = event instanceof CustomEvent
4734
+ ? event.detail?.value
4735
+ : undefined;
4736
+ this._values[name] = Number(value) || 0;
4737
+ if (!this._submitted)
4738
+ return;
4739
+ const field = fields.find((item) => item.name === name);
4740
+ if (!field)
4741
+ return;
4742
+ this._updateFieldValidation(field, this._validateField(field));
4743
+ });
4744
+ });
3770
4745
  // Passcode
3771
4746
  this.shadowRoot.querySelectorAll('.passcode-cell').forEach((cell) => {
3772
4747
  const name = cell.dataset.name;
@@ -3776,45 +4751,35 @@ class CardForm extends BaseElement {
3776
4751
  cell.addEventListener('input', () => {
3777
4752
  const v = cell.value.replace(/[^0-9]/g, '');
3778
4753
  cell.value = v.slice(0, 1);
3779
- const full = Array.from(cells).map((c) => c.value).join('');
3780
- this._values[name] = full;
4754
+ this._syncPasscodeValue(name, cells, fields);
3781
4755
  if (v && idx < cells.length - 1)
3782
4756
  cells[idx + 1].focus();
3783
4757
  });
3784
4758
  cell.addEventListener('keydown', (e) => {
3785
4759
  if (e.key === 'Backspace' && !cell.value && idx > 0) {
4760
+ e.preventDefault();
3786
4761
  cells[idx - 1].focus();
3787
4762
  cells[idx - 1].value = '';
4763
+ this._syncPasscodeValue(name, cells, fields);
3788
4764
  }
3789
4765
  });
3790
4766
  cell.addEventListener('paste', (e) => {
3791
4767
  e.preventDefault();
3792
4768
  const paste = (e.clipboardData?.getData('text') || '').replace(/[^0-9]/g, '');
4769
+ cells.forEach((item) => {
4770
+ item.value = '';
4771
+ });
3793
4772
  for (let j = 0; j < cells.length && j < paste.length; j++)
3794
4773
  cells[j].value = paste[j];
3795
- this._values[name] = Array.from(cells).map((c) => c.value).join('');
4774
+ this._syncPasscodeValue(name, cells, fields);
3796
4775
  cells[Math.min(paste.length, cells.length - 1)].focus();
3797
4776
  });
3798
4777
  cell.addEventListener('focus', () => cell.select());
3799
4778
  });
3800
- // Rate
3801
- this.shadowRoot.querySelectorAll('.rate-star').forEach((star) => {
3802
- star.addEventListener('click', () => {
3803
- const name = star.dataset.name;
3804
- const val = Number(star.dataset.value);
3805
- this._values[name] = this._values[name] === val ? 0 : val;
3806
- // Re-color siblings
3807
- const row = star.parentElement;
3808
- row.querySelectorAll('.rate-star').forEach((s) => {
3809
- const sv = Number(s.dataset.value);
3810
- s.style.color = sv <= this._values[name] ? '#fadb14' : '#e8e8e8';
3811
- });
3812
- });
3813
- });
3814
4779
  // Submit
3815
4780
  this.shadowRoot.querySelector('.submit-btn')?.addEventListener('click', () => {
4781
+ this._submitted = true;
3816
4782
  if (this._validate(fields)) {
3817
- this._submitted = true;
3818
4783
  this.dispatchEvent(new CustomEvent('submit', {
3819
4784
  bubbles: true,
3820
4785
  composed: true,
@@ -3823,29 +4788,147 @@ class CardForm extends BaseElement {
3823
4788
  }
3824
4789
  });
3825
4790
  }
4791
+ _syncPasscodeValue(name, cells, fields) {
4792
+ this._values[name] = Array.from(cells).map((item) => item.value).join('');
4793
+ if (!this._submitted)
4794
+ return;
4795
+ const field = fields.find((item) => item.name === name);
4796
+ if (field) {
4797
+ this._updateFieldValidation(field, this._validateField(field));
4798
+ }
4799
+ }
3826
4800
  _validate(fields) {
3827
4801
  this._errors = {};
3828
4802
  let valid = true;
3829
4803
  for (const f of fields) {
3830
- if (f.required) {
3831
- const val = this._values[f.name];
3832
- const isEmpty = val === '' || val === undefined || val === null || val === 0;
3833
- if (isEmpty) {
3834
- this._errors[f.name] = `${f.label || f.name}不能为空`;
3835
- valid = false;
3836
- }
3837
- if (f.type === 'passcode' && typeof val === 'string' && val.length < (f.length || 6)) {
3838
- this._errors[f.name] = `请输入完整的${f.label || '验证码'}`;
3839
- valid = false;
3840
- }
4804
+ const error = this._validateField(f);
4805
+ if (error) {
4806
+ this._errors[f.name] = error;
4807
+ valid = false;
3841
4808
  }
3842
4809
  }
3843
4810
  if (!valid)
3844
4811
  this.render();
3845
4812
  return valid;
3846
4813
  }
4814
+ _validateField(field) {
4815
+ const value = this._values[field.name];
4816
+ const rules = [...(field.rules ?? [])];
4817
+ if (field.required && !rules.some((rule) => rule.required)) {
4818
+ rules.unshift({ required: true });
4819
+ }
4820
+ const empty = value === undefined
4821
+ || value === null
4822
+ || value === 0
4823
+ || (typeof value === 'string' && value.trim() === '');
4824
+ for (const rule of rules) {
4825
+ const message = this._ruleMessage(field, rule);
4826
+ if (rule.required && empty)
4827
+ return message;
4828
+ if (empty)
4829
+ continue;
4830
+ const text = String(value);
4831
+ if (rule.pattern != null) {
4832
+ if (text.length > MAX_PATTERN_INPUT_LENGTH
4833
+ || !isSafeValidationPattern(rule.pattern)) {
4834
+ return message;
4835
+ }
4836
+ try {
4837
+ if (!new RegExp(rule.pattern).test(text))
4838
+ return message;
4839
+ }
4840
+ catch {
4841
+ return message;
4842
+ }
4843
+ }
4844
+ if (rule.minLength != null && text.length < rule.minLength) {
4845
+ return message;
4846
+ }
4847
+ if (rule.maxLength != null && text.length > rule.maxLength) {
4848
+ return message;
4849
+ }
4850
+ }
4851
+ const requiresValue = field.required || rules.some((rule) => rule.required);
4852
+ if (requiresValue
4853
+ && field.type === 'passcode'
4854
+ && typeof value === 'string'
4855
+ && value.length < (field.length || 6)) {
4856
+ return `请输入完整的${field.label || '验证码'}`;
4857
+ }
4858
+ return '';
4859
+ }
4860
+ _ruleMessage(field, rule) {
4861
+ if (rule.message)
4862
+ return rule.message;
4863
+ const label = field.label || field.name;
4864
+ if (rule.required)
4865
+ return `${label}不能为空`;
4866
+ if (rule.pattern != null)
4867
+ return `${label}格式不正确`;
4868
+ if (rule.minLength != null) {
4869
+ return `${label}至少输入${rule.minLength}个字符`;
4870
+ }
4871
+ if (rule.maxLength != null) {
4872
+ return `${label}最多输入${rule.maxLength}个字符`;
4873
+ }
4874
+ return `${label}输入不正确`;
4875
+ }
4876
+ _updateFieldValidation(field, error) {
4877
+ if (!this.shadowRoot)
4878
+ return;
4879
+ this._errors[field.name] = error;
4880
+ const fieldElement = Array.from(this.shadowRoot.querySelectorAll('.form-field')).find((element) => {
4881
+ const control = element.querySelector('[data-name]');
4882
+ return control?.dataset.name === field.name;
4883
+ });
4884
+ if (!fieldElement)
4885
+ return;
4886
+ const errorElement = fieldElement.querySelector('.field-error');
4887
+ const fieldControl = fieldElement.querySelector('.field-control');
4888
+ const formSelect = fieldElement.querySelector('.form-select');
4889
+ const formRate = fieldElement.querySelector('.form-rate');
4890
+ const controls = fieldElement.querySelectorAll('.field-input, .form-select, .form-rate, .passcode-cell');
4891
+ errorElement.textContent = error;
4892
+ fieldControl?.classList.toggle('error', Boolean(error));
4893
+ if (formSelect) {
4894
+ formSelect.updateProps(this._selectProps(field, error, Boolean(this._props.disabled)), this._isMobile);
4895
+ }
4896
+ if (formRate) {
4897
+ formRate.updateProps(this._rateProps(field, error, Boolean(this._props.disabled)), this._isMobile);
4898
+ }
4899
+ controls.forEach((control) => {
4900
+ if (control.classList.contains('passcode-cell')) {
4901
+ control.classList.toggle('error', Boolean(error));
4902
+ }
4903
+ control.setAttribute('aria-invalid', String(Boolean(error)));
4904
+ const descriptionIds = [
4905
+ control.dataset.affixDescribedby,
4906
+ error ? errorElement.id : '',
4907
+ ].filter(Boolean).join(' ');
4908
+ if (descriptionIds) {
4909
+ control.setAttribute('aria-describedby', descriptionIds);
4910
+ }
4911
+ else {
4912
+ control.removeAttribute('aria-describedby');
4913
+ }
4914
+ });
4915
+ }
4916
+ _escapeHtml(value) {
4917
+ return String(value)
4918
+ .replace(/&/g, '&amp;')
4919
+ .replace(/</g, '&lt;')
4920
+ .replace(/>/g, '&gt;');
4921
+ }
4922
+ _escapeAttr(value) {
4923
+ return this._escapeHtml(value).replace(/"/g, '&quot;');
4924
+ }
4925
+ _domId(value) {
4926
+ const normalized = String(value).replace(/[^a-zA-Z0-9_-]/g, '-');
4927
+ return normalized || 'form';
4928
+ }
3847
4929
  updateProps(props, isMobile) {
3848
4930
  this._errors = {};
4931
+ this._submitted = false;
3849
4932
  super.updateProps(props, isMobile);
3850
4933
  }
3851
4934
  }
@@ -4150,6 +5233,7 @@ if (typeof customElements !== 'undefined' && !customElements.get(CardProgress.is
4150
5233
  * { "title": "Final review", "status": "pending" }
4151
5234
  * ],
4152
5235
  * "iconSize": 28,
5236
+ * "textLayout": "vertical",
4153
5237
  * "titleProps": { "fontSize": "14px", "fontWeight": 500, "color": "#1a1a1a" },
4154
5238
  * "descriptionProps": { "fontSize": "12px", "color": "#8c8c8c" },
4155
5239
  * "connector": { "width": 2, "minHeight": 24, "style": "solid", "color": "#e8e8e8", "completedColor": "#52c41a" }
@@ -4217,12 +5301,13 @@ class CardSteps extends BaseElement {
4217
5301
  render() {
4218
5302
  if (!this.shadowRoot || !this._node)
4219
5303
  return;
4220
- const { items = [], iconSize: iconSizeProp = 28, titleProps = {}, descriptionProps = {}, connector = {}, style,
5304
+ const { items = [], iconSize: iconSizeProp = 28, textLayout: textLayoutProp = 'vertical', titleProps = {}, descriptionProps = {}, connector = {}, style,
4221
5305
  // Keep backward compat: old "size" prop
4222
5306
  size, isExpressionResultStyle, } = this._props;
4223
5307
  const inlineStyle = this.buildInlineStyle(style, isExpressionResultStyle);
4224
5308
  // iconSize: prefer iconSize prop, fallback to legacy "size" prop
4225
5309
  const iconSize = Number(iconSizeProp != null ? iconSizeProp : size) || 28;
5310
+ const textLayout = textLayoutProp === 'horizontal' ? 'horizontal' : 'vertical';
4226
5311
  // Runtime guard: items must be an array
4227
5312
  const stepItems = Array.isArray(items) ? items : [];
4228
5313
  if (!Array.isArray(items)) {
@@ -4284,7 +5369,7 @@ class CardSteps extends BaseElement {
4284
5369
  <div class="step-item" data-status="${status}">
4285
5370
  <div class="step-head${item.clickable ? ' step-clickable' : ''}" data-index="${index}">
4286
5371
  <div class="step-icon"${iconStyle}>${iconHTML}</div>
4287
- <div class="step-text">
5372
+ <div class="step-text step-text-${textLayout}">
4288
5373
  <div class="step-title">${title}</div>
4289
5374
  ${descHTML}
4290
5375
  </div>
@@ -4344,11 +5429,28 @@ class CardSteps extends BaseElement {
4344
5429
  line-height: 1;
4345
5430
  user-select: none;
4346
5431
  }
4347
- .step-text {
4348
- display: flex;
4349
- flex-direction: column;
4350
- justify-content: center;
4351
- min-height: ${iconSize}px;
5432
+ .step-text {
5433
+ display: flex;
5434
+ flex-direction: column;
5435
+ justify-content: center;
5436
+ flex: 1;
5437
+ min-width: 0;
5438
+ min-height: ${iconSize}px;
5439
+ }
5440
+ .step-text-horizontal {
5441
+ flex-direction: row;
5442
+ align-items: center;
5443
+ justify-content: space-between;
5444
+ gap: 12px;
5445
+ }
5446
+ .step-text-horizontal .step-title {
5447
+ min-width: 0;
5448
+ }
5449
+ .step-text-horizontal .step-description {
5450
+ flex-shrink: 0;
5451
+ margin-top: 0;
5452
+ text-align: right;
5453
+ white-space: nowrap;
4352
5454
  }
4353
5455
  .step-title {
4354
5456
  font-size: ${titleFontSize};
@@ -5437,9 +6539,680 @@ class CardVideo extends BaseElement {
5437
6539
  }
5438
6540
  }
5439
6541
  }
5440
- CardVideo.is = 'ai-card-video';
5441
- if (typeof customElements !== 'undefined' && !customElements.get(CardVideo.is)) {
5442
- customElements.define(CardVideo.is, CardVideo);
6542
+ CardVideo.is = 'ai-card-video';
6543
+ if (typeof customElements !== 'undefined' && !customElements.get(CardVideo.is)) {
6544
+ customElements.define(CardVideo.is, CardVideo);
6545
+ }
6546
+
6547
+ class CardSwitch extends BaseElement {
6548
+ constructor() {
6549
+ super(...arguments);
6550
+ this._currentChecked = null;
6551
+ }
6552
+ render() {
6553
+ if (!this.shadowRoot || !this._node)
6554
+ return;
6555
+ const { checked, defaultChecked = false, disabled = false, readonly: readOnly = false, size = 22, activeColor = '#626a78', inactiveColor = '#a9afb9', thumbColor = '#ffffff', ariaLabel = 'Switch', style, trackStyle, thumbStyle, isExpressionResultStyle, } = this._props;
6556
+ const initial = Boolean(checked ?? defaultChecked);
6557
+ const current = this._currentChecked ?? initial;
6558
+ const numericSize = Number(size);
6559
+ const trackHeight = Number.isFinite(numericSize) && numericSize > 0
6560
+ ? numericSize
6561
+ : 22;
6562
+ const trackWidth = Math.round(trackHeight * 2);
6563
+ const thumbSize = Math.max(8, trackHeight - 4);
6564
+ const rootInline = this.buildInlineStyle(style, isExpressionResultStyle);
6565
+ const trackInline = this.buildInlineStyle({
6566
+ width: trackWidth,
6567
+ height: trackHeight,
6568
+ ...(trackStyle || {}),
6569
+ backgroundColor: current ? activeColor : inactiveColor,
6570
+ }, isExpressionResultStyle);
6571
+ const thumbInline = this.buildInlineStyle({
6572
+ width: thumbSize,
6573
+ height: thumbSize,
6574
+ ...(thumbStyle || {}),
6575
+ backgroundColor: thumbColor,
6576
+ }, isExpressionResultStyle);
6577
+ this.shadowRoot.innerHTML = `
6578
+ <style>
6579
+ :host {
6580
+ display: inline-flex;
6581
+ box-sizing: border-box;
6582
+ vertical-align: middle;
6583
+ -webkit-tap-highlight-color: transparent;
6584
+ }
6585
+ .switch-root {
6586
+ display: inline-flex;
6587
+ box-sizing: border-box;
6588
+ }
6589
+ .switch-track {
6590
+ position: relative;
6591
+ display: inline-block;
6592
+ box-sizing: border-box;
6593
+ min-width: 28px;
6594
+ min-height: 16px;
6595
+ padding: 0;
6596
+ margin: 0;
6597
+ border: none;
6598
+ border-radius: 999px;
6599
+ cursor: pointer;
6600
+ transition: background-color 0.2s ease, box-shadow 0.2s ease;
6601
+ }
6602
+ .switch-track:focus-visible {
6603
+ outline: 2px solid #1677ff;
6604
+ outline-offset: 2px;
6605
+ }
6606
+ .switch-track:disabled {
6607
+ opacity: 0.45;
6608
+ cursor: not-allowed;
6609
+ }
6610
+ .switch-track[aria-readonly="true"] {
6611
+ cursor: default;
6612
+ }
6613
+ .switch-thumb {
6614
+ position: absolute;
6615
+ top: 50%;
6616
+ left: 2px;
6617
+ display: block;
6618
+ box-sizing: border-box;
6619
+ border-radius: 50%;
6620
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
6621
+ transform: translateY(-50%);
6622
+ transition: left 0.2s ease, transform 0.2s ease;
6623
+ pointer-events: none;
6624
+ }
6625
+ .switch-track.checked .switch-thumb {
6626
+ left: calc(100% - 2px);
6627
+ transform: translate(-100%, -50%);
6628
+ }
6629
+ </style>
6630
+ <div class="switch-root" style="${rootInline}">
6631
+ <button
6632
+ type="button"
6633
+ class="switch-track ${current ? 'checked' : ''}"
6634
+ part="track"
6635
+ role="switch"
6636
+ aria-checked="${current}"
6637
+ aria-label="${this.escapeAttr(String(ariaLabel))}"
6638
+ aria-readonly="${Boolean(readOnly)}"
6639
+ ${disabled ? 'disabled' : ''}
6640
+ style="${trackInline}"
6641
+ >
6642
+ <span class="switch-thumb" part="thumb" style="${thumbInline}"></span>
6643
+ </button>
6644
+ </div>
6645
+ `;
6646
+ const button = this.shadowRoot.querySelector('.switch-track');
6647
+ button?.addEventListener('click', () => {
6648
+ if (disabled || readOnly)
6649
+ return;
6650
+ this.commit(!current, current, 'pointer');
6651
+ });
6652
+ button?.addEventListener('keydown', (event) => {
6653
+ if (disabled
6654
+ || readOnly
6655
+ || (event.key !== ' ' && event.key !== 'Enter')) {
6656
+ return;
6657
+ }
6658
+ event.preventDefault();
6659
+ this.commit(!current, current, 'keyboard');
6660
+ });
6661
+ }
6662
+ updateProps(props, isMobile) {
6663
+ this._currentChecked = null;
6664
+ super.updateProps(props, isMobile);
6665
+ }
6666
+ commit(next, previous, source) {
6667
+ if (next === previous)
6668
+ return;
6669
+ this._currentChecked = next;
6670
+ this.render();
6671
+ emitValueChange(this, next, previous, source, { checked: next });
6672
+ }
6673
+ escapeAttr(value) {
6674
+ return value
6675
+ .replace(/&/g, '&amp;')
6676
+ .replace(/"/g, '&quot;')
6677
+ .replace(/</g, '&lt;')
6678
+ .replace(/>/g, '&gt;');
6679
+ }
6680
+ }
6681
+ CardSwitch.is = 'ai-card-switch';
6682
+ if (typeof customElements !== 'undefined' && !customElements.get(CardSwitch.is)) {
6683
+ customElements.define(CardSwitch.is, CardSwitch);
6684
+ }
6685
+
6686
+ const INTERACTIVE_TAGS = new Set([
6687
+ 'A',
6688
+ 'AUDIO',
6689
+ 'BUTTON',
6690
+ 'INPUT',
6691
+ 'SELECT',
6692
+ 'TEXTAREA',
6693
+ 'VIDEO',
6694
+ ]);
6695
+ const INTERACTIVE_CARD_TYPES = new Set([
6696
+ 'Button',
6697
+ 'Counter',
6698
+ 'DatePicker',
6699
+ 'Input',
6700
+ 'PasscodeInput',
6701
+ 'Rate',
6702
+ 'Select',
6703
+ 'Switch',
6704
+ ]);
6705
+ class CardChoiceItem extends BaseElement {
6706
+ constructor() {
6707
+ super(...arguments);
6708
+ this._context = {
6709
+ selectionMode: 'single',
6710
+ variant: 'plain',
6711
+ selected: false,
6712
+ listDisabled: false,
6713
+ listReadonly: false,
6714
+ selectionBlocked: false,
6715
+ tabIndex: 0,
6716
+ indicatorPosition: 'start',
6717
+ };
6718
+ }
6719
+ get value() {
6720
+ return String(this._props.value ?? '');
6721
+ }
6722
+ get itemDisabled() {
6723
+ return Boolean(this._props.disabled);
6724
+ }
6725
+ setSelectionContext(context) {
6726
+ this._context = context;
6727
+ this.render();
6728
+ }
6729
+ focusIndicator() {
6730
+ this.shadowRoot
6731
+ ?.querySelector('.choice-indicator')
6732
+ ?.focus();
6733
+ }
6734
+ render() {
6735
+ if (!this.shadowRoot || !this._node)
6736
+ return;
6737
+ const { disabled = false, ariaLabel = this.value || 'Choice', style, selectedStyle, indicatorStyle, selectedIndicatorStyle, checkedIcon, isExpressionResultStyle, } = this._props;
6738
+ const { selectionMode, variant, selected, listDisabled, listReadonly, selectionBlocked, tabIndex, indicatorPosition, itemStyle, selectedItemStyle, indicatorStyle: listIndicatorStyle, selectedIndicatorStyle: listSelectedIndicatorStyle, checkedIcon: listCheckedIcon, } = this._context;
6739
+ const fullyDisabled = Boolean(disabled || listDisabled);
6740
+ const selectionUnavailable = fullyDisabled || listReadonly;
6741
+ const rootInline = this.buildInlineStyle({
6742
+ ...(itemStyle || {}),
6743
+ ...(style || {}),
6744
+ ...(selected ? selectedItemStyle || {} : {}),
6745
+ ...(selected ? selectedStyle || {} : {}),
6746
+ }, isExpressionResultStyle);
6747
+ const indicatorInline = this.buildInlineStyle({
6748
+ ...(listIndicatorStyle || {}),
6749
+ ...(indicatorStyle || {}),
6750
+ ...(selected ? listSelectedIndicatorStyle || {} : {}),
6751
+ ...(selected ? selectedIndicatorStyle || {} : {}),
6752
+ }, isExpressionResultStyle);
6753
+ const indicatorRole = selectionMode === 'single' ? 'radio' : 'checkbox';
6754
+ const resolvedCheckedIcon = checkedIcon ?? listCheckedIcon ?? {
6755
+ name: 'check_bold',
6756
+ size: 12,
6757
+ };
6758
+ const checkedMark = selectionMode === 'multiple'
6759
+ ? `<span class="choice-icon" aria-hidden="true">${renderIconContent(resolvedCheckedIcon)}</span>`
6760
+ : '<span class="radio-dot" aria-hidden="true"></span>';
6761
+ this.setAttribute('data-selected', String(selected));
6762
+ this.toggleAttribute('data-disabled', fullyDisabled);
6763
+ this.toggleAttribute('data-selection-blocked', selectionBlocked);
6764
+ this.shadowRoot.innerHTML = `
6765
+ <style>
6766
+ :host {
6767
+ display: block;
6768
+ box-sizing: border-box;
6769
+ }
6770
+ .choice-item {
6771
+ display: flex;
6772
+ align-items: center;
6773
+ gap: 12px;
6774
+ box-sizing: border-box;
6775
+ min-width: 0;
6776
+ padding: 6px 0;
6777
+ border: 1px solid transparent;
6778
+ border-radius: 8px;
6779
+ background: transparent;
6780
+ color: #343b4a;
6781
+ cursor: pointer;
6782
+ transition: border-color 0.15s ease, box-shadow 0.15s ease,
6783
+ background-color 0.15s ease;
6784
+ }
6785
+ .choice-item.outlined {
6786
+ padding: 10px 12px;
6787
+ border-color: #d9dce2;
6788
+ background: #fff;
6789
+ }
6790
+ .choice-item.outlined.selected {
6791
+ border-color: #626a78;
6792
+ background: #f6f7f9;
6793
+ }
6794
+ .choice-item.disabled {
6795
+ opacity: 0.45;
6796
+ cursor: not-allowed;
6797
+ }
6798
+ .choice-item.readonly {
6799
+ cursor: default;
6800
+ }
6801
+ .choice-indicator {
6802
+ width: 20px;
6803
+ height: 20px;
6804
+ min-width: 20px;
6805
+ display: inline-flex;
6806
+ align-items: center;
6807
+ justify-content: center;
6808
+ box-sizing: border-box;
6809
+ padding: 0;
6810
+ border: 2px solid #c9cdd4;
6811
+ border-radius: ${selectionMode === 'single' ? '50%' : '5px'};
6812
+ background: #fff;
6813
+ color: #fff;
6814
+ cursor: pointer;
6815
+ }
6816
+ .choice-indicator.checked {
6817
+ border-color: #626a78;
6818
+ background: #626a78;
6819
+ }
6820
+ .choice-indicator[aria-disabled="true"] {
6821
+ cursor: not-allowed;
6822
+ }
6823
+ .choice-indicator:focus-visible {
6824
+ outline: 2px solid #1677ff;
6825
+ outline-offset: 2px;
6826
+ }
6827
+ .radio-dot {
6828
+ width: 8px;
6829
+ height: 8px;
6830
+ border-radius: 50%;
6831
+ background: currentColor;
6832
+ }
6833
+ .choice-icon,
6834
+ .choice-icon svg,
6835
+ .choice-icon img {
6836
+ display: block;
6837
+ flex: none;
6838
+ }
6839
+ .choice-content {
6840
+ flex: 1;
6841
+ min-width: 0;
6842
+ }
6843
+ </style>
6844
+ <div
6845
+ class="choice-item ${variant} ${selected ? 'selected' : ''} ${fullyDisabled ? 'disabled' : ''} ${listReadonly ? 'readonly' : ''}"
6846
+ part="item"
6847
+ style="${rootInline}"
6848
+ >
6849
+ ${indicatorPosition === 'start' ? this.renderIndicator(indicatorRole, selected, selectionUnavailable, tabIndex, ariaLabel, indicatorInline, checkedMark) : ''}
6850
+ <div class="choice-content" part="content" ${fullyDisabled ? 'inert' : ''}>
6851
+ <slot></slot>
6852
+ </div>
6853
+ ${indicatorPosition === 'end' ? this.renderIndicator(indicatorRole, selected, selectionUnavailable, tabIndex, ariaLabel, indicatorInline, checkedMark) : ''}
6854
+ </div>
6855
+ `;
6856
+ const root = this.shadowRoot.querySelector('.choice-item');
6857
+ const indicator = this.shadowRoot.querySelector('.choice-indicator');
6858
+ indicator?.addEventListener('click', (event) => {
6859
+ event.stopPropagation();
6860
+ this.requestChange('pointer');
6861
+ });
6862
+ indicator?.addEventListener('keydown', (event) => {
6863
+ if (event.key === ' ' || event.key === 'Enter') {
6864
+ event.preventDefault();
6865
+ event.stopPropagation();
6866
+ this.requestChange('keyboard');
6867
+ return;
6868
+ }
6869
+ if (event.key === 'ArrowDown'
6870
+ || event.key === 'ArrowRight'
6871
+ || event.key === 'ArrowUp'
6872
+ || event.key === 'ArrowLeft'
6873
+ || event.key === 'Home'
6874
+ || event.key === 'End') {
6875
+ event.preventDefault();
6876
+ event.stopPropagation();
6877
+ this.dispatchEvent(new CustomEvent('choice-navigate', {
6878
+ bubbles: true,
6879
+ composed: true,
6880
+ detail: {
6881
+ value: this.value,
6882
+ key: event.key,
6883
+ },
6884
+ }));
6885
+ }
6886
+ });
6887
+ root?.addEventListener('click', (event) => {
6888
+ if (this.hasInteractiveTarget(event))
6889
+ return;
6890
+ this.requestChange('pointer');
6891
+ });
6892
+ }
6893
+ renderIndicator(role, selected, unavailable, tabIndex, ariaLabel, inlineStyle, mark) {
6894
+ return `
6895
+ <button
6896
+ type="button"
6897
+ class="choice-indicator ${selected ? 'checked' : ''}"
6898
+ part="indicator"
6899
+ role="${role}"
6900
+ aria-checked="${selected}"
6901
+ aria-disabled="${unavailable}"
6902
+ tabindex="${unavailable ? -1 : tabIndex}"
6903
+ ${unavailable ? 'disabled' : ''}
6904
+ aria-label="${this.escapeAttr(String(ariaLabel))}"
6905
+ style="${inlineStyle}"
6906
+ >${selected ? mark : ''}</button>
6907
+ `;
6908
+ }
6909
+ requestChange(source) {
6910
+ if (this.itemDisabled
6911
+ || this._context.listDisabled
6912
+ || this._context.listReadonly) {
6913
+ return;
6914
+ }
6915
+ this.dispatchEvent(new CustomEvent('choice-request-change', {
6916
+ bubbles: true,
6917
+ composed: true,
6918
+ detail: {
6919
+ value: this.value,
6920
+ source,
6921
+ },
6922
+ }));
6923
+ }
6924
+ hasInteractiveTarget(event) {
6925
+ for (const target of event.composedPath()) {
6926
+ if (target === this)
6927
+ break;
6928
+ if (!(target instanceof HTMLElement))
6929
+ continue;
6930
+ if (INTERACTIVE_TAGS.has(target.tagName))
6931
+ return true;
6932
+ if (target.isContentEditable)
6933
+ return true;
6934
+ const cardType = target.getAttribute('data-card-type');
6935
+ if (cardType && INTERACTIVE_CARD_TYPES.has(cardType))
6936
+ return true;
6937
+ }
6938
+ return false;
6939
+ }
6940
+ escapeAttr(value) {
6941
+ return value
6942
+ .replace(/&/g, '&amp;')
6943
+ .replace(/"/g, '&quot;')
6944
+ .replace(/</g, '&lt;')
6945
+ .replace(/>/g, '&gt;');
6946
+ }
6947
+ }
6948
+ CardChoiceItem.is = 'ai-card-choice-item';
6949
+ if (typeof customElements !== 'undefined'
6950
+ && !customElements.get(CardChoiceItem.is)) {
6951
+ customElements.define(CardChoiceItem.is, CardChoiceItem);
6952
+ }
6953
+
6954
+ class CardChoiceList extends BaseElement {
6955
+ constructor() {
6956
+ super(...arguments);
6957
+ this._onChoiceRequest = (event) => {
6958
+ const sourceItem = event.target;
6959
+ if (!(sourceItem instanceof CardChoiceItem)
6960
+ || sourceItem.parentElement !== this) {
6961
+ return;
6962
+ }
6963
+ const customEvent = event;
6964
+ event.stopPropagation();
6965
+ this.commitRequest(customEvent.detail.value, customEvent.detail.source);
6966
+ };
6967
+ this._onChoiceNavigate = (event) => {
6968
+ const sourceItem = event.target;
6969
+ if (!(sourceItem instanceof CardChoiceItem)
6970
+ || sourceItem.parentElement !== this) {
6971
+ return;
6972
+ }
6973
+ const customEvent = event;
6974
+ event.stopPropagation();
6975
+ this.navigateFrom(customEvent.detail.value, customEvent.detail.key);
6976
+ };
6977
+ }
6978
+ render() {
6979
+ if (!this.shadowRoot || !this._node)
6980
+ return;
6981
+ const { selectionMode = 'single', value, defaultValue, disabled = false, readonly: readOnly = false, direction = 'vertical', gap = 8, ariaLabel = 'Choices', style, isExpressionResultStyle, } = this._props;
6982
+ const mode = selectionMode === 'multiple' ? 'multiple' : 'single';
6983
+ if (this._currentValue === undefined) {
6984
+ this._currentValue = this.normalizeValue(value !== undefined ? value : defaultValue, mode);
6985
+ }
6986
+ const inlineStyle = this.buildInlineStyle({
6987
+ display: 'flex',
6988
+ flexDirection: direction === 'horizontal' ? 'row' : 'column',
6989
+ gap,
6990
+ ...(style || {}),
6991
+ }, isExpressionResultStyle);
6992
+ this.removeEventListener('choice-request-change', this._onChoiceRequest);
6993
+ this.addEventListener('choice-request-change', this._onChoiceRequest);
6994
+ this.removeEventListener('choice-navigate', this._onChoiceNavigate);
6995
+ this.addEventListener('choice-navigate', this._onChoiceNavigate);
6996
+ this.shadowRoot.innerHTML = `
6997
+ <style>
6998
+ :host {
6999
+ display: block;
7000
+ box-sizing: border-box;
7001
+ min-width: 0;
7002
+ }
7003
+ .choice-list {
7004
+ box-sizing: border-box;
7005
+ min-width: 0;
7006
+ }
7007
+ </style>
7008
+ <div
7009
+ class="choice-list"
7010
+ part="list"
7011
+ role="${mode === 'single' ? 'radiogroup' : 'group'}"
7012
+ aria-label="${this.escapeAttr(String(ariaLabel))}"
7013
+ aria-disabled="${Boolean(disabled)}"
7014
+ aria-readonly="${Boolean(readOnly)}"
7015
+ style="${inlineStyle}"
7016
+ >
7017
+ <slot></slot>
7018
+ </div>
7019
+ `;
7020
+ this.shadowRoot.querySelector('slot')?.addEventListener('slotchange', () => this.syncItems());
7021
+ queueMicrotask(() => this.syncItems());
7022
+ }
7023
+ updateProps(props, isMobile) {
7024
+ this._currentValue = undefined;
7025
+ super.updateProps(props, isMobile);
7026
+ }
7027
+ commitRequest(value, source) {
7028
+ if (this._props.disabled || this._props.readonly)
7029
+ return;
7030
+ const mode = this.mode;
7031
+ const previousValue = this.cloneValue(this.currentValue);
7032
+ let nextValue;
7033
+ let checked;
7034
+ if (mode === 'multiple') {
7035
+ const current = this.values;
7036
+ const index = current.indexOf(value);
7037
+ if (index >= 0) {
7038
+ nextValue = current.filter((entry) => entry !== value);
7039
+ checked = false;
7040
+ }
7041
+ else {
7042
+ const maxSelected = this.maxSelected;
7043
+ if (maxSelected != null && current.length >= maxSelected) {
7044
+ this.dispatchEvent(new CustomEvent('max-reached', {
7045
+ bubbles: true,
7046
+ composed: true,
7047
+ detail: {
7048
+ maxSelected,
7049
+ attemptedValue: value,
7050
+ values: current,
7051
+ },
7052
+ }));
7053
+ return;
7054
+ }
7055
+ nextValue = [...current, value];
7056
+ checked = true;
7057
+ }
7058
+ }
7059
+ else {
7060
+ const current = typeof this.currentValue === 'string'
7061
+ ? this.currentValue
7062
+ : null;
7063
+ if (current === value) {
7064
+ if (!this._props.allowClear)
7065
+ return;
7066
+ nextValue = null;
7067
+ checked = false;
7068
+ }
7069
+ else {
7070
+ nextValue = value;
7071
+ checked = true;
7072
+ }
7073
+ }
7074
+ this._currentValue = nextValue;
7075
+ this.syncItems();
7076
+ const values = this.values;
7077
+ emitValueChange(this, nextValue, previousValue, source, {
7078
+ values,
7079
+ changedValue: value,
7080
+ checked,
7081
+ selectionMode: mode,
7082
+ });
7083
+ }
7084
+ syncItems() {
7085
+ if (!this._node)
7086
+ return;
7087
+ const seen = new Set();
7088
+ const selected = new Set(this.values);
7089
+ const maxSelected = this.maxSelected;
7090
+ const maxReached = (this.mode === 'multiple'
7091
+ && maxSelected != null
7092
+ && selected.size >= maxSelected);
7093
+ const { disabled = false, readonly: readOnly = false, variant = 'plain', indicatorPosition = 'start', itemStyle, selectedItemStyle, indicatorStyle, selectedIndicatorStyle, checkedIcon, } = this._props;
7094
+ const items = Array.from(this.children).filter((child) => child instanceof CardChoiceItem);
7095
+ const selectedFocusable = items.find((item) => selected.has(item.value) && !item.itemDisabled);
7096
+ const firstFocusable = items.find((item) => !item.itemDisabled);
7097
+ const tabStop = selectedFocusable ?? firstFocusable;
7098
+ for (const child of items) {
7099
+ if (!(child instanceof CardChoiceItem))
7100
+ continue;
7101
+ const duplicate = seen.has(child.value);
7102
+ seen.add(child.value);
7103
+ const isSelected = selected.has(child.value);
7104
+ const context = {
7105
+ selectionMode: this.mode,
7106
+ variant: variant === 'outlined' ? 'outlined' : 'plain',
7107
+ selected: isSelected,
7108
+ listDisabled: Boolean(disabled || duplicate),
7109
+ listReadonly: Boolean(readOnly),
7110
+ selectionBlocked: Boolean(maxReached && !isSelected),
7111
+ tabIndex: (!disabled
7112
+ && !readOnly
7113
+ && !duplicate
7114
+ && child === tabStop) ? 0 : -1,
7115
+ indicatorPosition: indicatorPosition === 'end' ? 'end' : 'start',
7116
+ itemStyle,
7117
+ selectedItemStyle,
7118
+ indicatorStyle,
7119
+ selectedIndicatorStyle,
7120
+ checkedIcon,
7121
+ };
7122
+ child.setSelectionContext(context);
7123
+ }
7124
+ }
7125
+ navigateFrom(value, key) {
7126
+ if (this._props.disabled || this._props.readonly)
7127
+ return;
7128
+ const seen = new Set();
7129
+ const items = Array.from(this.children).filter((child) => {
7130
+ if (!(child instanceof CardChoiceItem) || child.itemDisabled) {
7131
+ return false;
7132
+ }
7133
+ if (seen.has(child.value))
7134
+ return false;
7135
+ seen.add(child.value);
7136
+ return true;
7137
+ });
7138
+ if (items.length === 0)
7139
+ return;
7140
+ const currentIndex = Math.max(0, items.findIndex((item) => item.value === value));
7141
+ let nextIndex = currentIndex;
7142
+ if (key === 'Home') {
7143
+ nextIndex = 0;
7144
+ }
7145
+ else if (key === 'End') {
7146
+ nextIndex = items.length - 1;
7147
+ }
7148
+ else if (key === 'ArrowDown' || key === 'ArrowRight') {
7149
+ nextIndex = (currentIndex + 1) % items.length;
7150
+ }
7151
+ else if (key === 'ArrowUp' || key === 'ArrowLeft') {
7152
+ nextIndex = (currentIndex - 1 + items.length) % items.length;
7153
+ }
7154
+ const target = items[nextIndex];
7155
+ if (!target || target.value === value) {
7156
+ target?.focusIndicator();
7157
+ return;
7158
+ }
7159
+ if (this.mode === 'single') {
7160
+ this.commitRequest(target.value, 'keyboard');
7161
+ }
7162
+ queueMicrotask(() => target.focusIndicator());
7163
+ }
7164
+ get mode() {
7165
+ return this._props.selectionMode === 'multiple' ? 'multiple' : 'single';
7166
+ }
7167
+ get currentValue() {
7168
+ if (this._currentValue === undefined) {
7169
+ this._currentValue = this.normalizeValue(this._props.value !== undefined
7170
+ ? this._props.value
7171
+ : this._props.defaultValue, this.mode);
7172
+ }
7173
+ return this._currentValue;
7174
+ }
7175
+ get values() {
7176
+ const value = this.currentValue;
7177
+ if (Array.isArray(value))
7178
+ return [...value];
7179
+ return typeof value === 'string' && value ? [value] : [];
7180
+ }
7181
+ get maxSelected() {
7182
+ if (this.mode !== 'multiple')
7183
+ return null;
7184
+ const value = Number(this._props.maxSelected);
7185
+ return Number.isInteger(value) && value > 0 ? value : null;
7186
+ }
7187
+ normalizeValue(input, mode) {
7188
+ if (mode === 'multiple') {
7189
+ const source = Array.isArray(input)
7190
+ ? input
7191
+ : input == null || input === ''
7192
+ ? []
7193
+ : [input];
7194
+ return Array.from(new Set(source.map((entry) => String(entry))));
7195
+ }
7196
+ if (Array.isArray(input)) {
7197
+ return input.length > 0 ? String(input[0]) : null;
7198
+ }
7199
+ return input == null || input === '' ? null : String(input);
7200
+ }
7201
+ cloneValue(value) {
7202
+ return Array.isArray(value) ? [...value] : value;
7203
+ }
7204
+ escapeAttr(value) {
7205
+ return value
7206
+ .replace(/&/g, '&amp;')
7207
+ .replace(/"/g, '&quot;')
7208
+ .replace(/</g, '&lt;')
7209
+ .replace(/>/g, '&gt;');
7210
+ }
7211
+ }
7212
+ CardChoiceList.is = 'ai-card-choice-list';
7213
+ if (typeof customElements !== 'undefined'
7214
+ && !customElements.get(CardChoiceList.is)) {
7215
+ customElements.define(CardChoiceList.is, CardChoiceList);
5443
7216
  }
5444
7217
 
5445
7218
  /**
@@ -5591,6 +7364,24 @@ function renderVideo(node, props, isMobile) {
5591
7364
  el.setData(node, props, isMobile);
5592
7365
  return el;
5593
7366
  }
7367
+ /** Render a Switch node. */
7368
+ function renderSwitch(node, props, isMobile) {
7369
+ const el = document.createElement(CardSwitch.is);
7370
+ el.setData(node, props, isMobile);
7371
+ return el;
7372
+ }
7373
+ /** Render a ChoiceList node. */
7374
+ function renderChoiceList(node, props, isMobile) {
7375
+ const el = document.createElement(CardChoiceList.is);
7376
+ el.setData(node, props, isMobile);
7377
+ return el;
7378
+ }
7379
+ /** Render a ChoiceItem node. */
7380
+ function renderChoiceItem(node, props, isMobile) {
7381
+ const el = document.createElement(CardChoiceItem.is);
7382
+ el.setData(node, props, isMobile);
7383
+ return el;
7384
+ }
5594
7385
  /**
5595
7386
  * Fallback renderer for unknown / container component types.
5596
7387
  * Uses a plain `<div>` (no Shadow DOM) so that child elements
@@ -5634,6 +7425,9 @@ const componentRenderers = {
5634
7425
  DatePicker: renderDatePicker,
5635
7426
  Audio: renderAudio,
5636
7427
  Video: renderVideo,
7428
+ Switch: renderSwitch,
7429
+ ChoiceList: renderChoiceList,
7430
+ ChoiceItem: renderChoiceItem,
5637
7431
  _default: renderDefault,
5638
7432
  };
5639
7433
  /**
@@ -5661,6 +7455,438 @@ function resolveSizeInStyle(style) {
5661
7455
  return resolved;
5662
7456
  }
5663
7457
 
7458
+ /**
7459
+ * Render the scoped/materialized branch of a card.
7460
+ *
7461
+ * This implementation deliberately owns its state independently from the
7462
+ * legacy static renderer. Bound writes happen in isolated drafts and publish
7463
+ * only after materialization and detached DOM construction both succeed.
7464
+ */
7465
+ function renderBoundCard(container, schema, options) {
7466
+ const variables = cloneJsonData({
7467
+ ...schema.variables,
7468
+ ...options.variables,
7469
+ });
7470
+ const schemaActions = schema.actions ?? {};
7471
+ const lifecycleManager = createLifecycleManager();
7472
+ const abortController = new AbortController();
7473
+ const inflightRequests = new Map();
7474
+ const activeLifecycleNodes = new Map();
7475
+ let currentMaterialized;
7476
+ let revision = 0;
7477
+ let disposed = false;
7478
+ let isMobile = options.isMobile ?? isMobileViewport();
7479
+ let actionQueue = Promise.resolve();
7480
+ let lifecycleQueue = Promise.resolve();
7481
+ const removeViewportListener = options.isMobile == null
7482
+ ? onViewportChange((mobile) => {
7483
+ if (disposed)
7484
+ return;
7485
+ isMobile = mobile;
7486
+ const candidate = prepareCandidate(variables);
7487
+ publishDOM(candidate, false);
7488
+ })
7489
+ : () => { };
7490
+ function expressionContextFor(node) {
7491
+ return isBoundRenderTreeNode(node)
7492
+ ? createExpressionContext(node.scope)
7493
+ : variables;
7494
+ }
7495
+ function resolveNodeValue(value, node, renderVariables) {
7496
+ if (node.bindingDialect === 'a2ui') {
7497
+ return resolveA2UIDeep(value, renderVariables, node.dataPath);
7498
+ }
7499
+ if (typeof value === 'string' && hasExpression(value)) {
7500
+ return resolveExpression(value, expressionContextFor(node));
7501
+ }
7502
+ return value;
7503
+ }
7504
+ function resolveNodeProps(node, renderVariables) {
7505
+ const resolvedProps = (node.bindingDialect === 'a2ui'
7506
+ ? resolveA2UIDeep(node.props, renderVariables, node.dataPath)
7507
+ : resolveDeep(node.props, expressionContextFor(node)));
7508
+ if (resolvedProps.content
7509
+ && typeof resolvedProps.content === 'object'
7510
+ && 'type' in resolvedProps.content) {
7511
+ resolvedProps.content = resolveExpressionValue(resolvedProps.content, expressionContextFor(node));
7512
+ }
7513
+ return resolvedProps;
7514
+ }
7515
+ function createPassiveActionContext(renderVariables) {
7516
+ return {
7517
+ ...createWebActionContext({
7518
+ ...options,
7519
+ abortSignal: abortController.signal,
7520
+ }),
7521
+ variables: renderVariables,
7522
+ botId: options.botId,
7523
+ inflightRequests,
7524
+ };
7525
+ }
7526
+ function renderNode(node, renderVariables, lifecycleIds) {
7527
+ if (node.directives?.visible) {
7528
+ const resolved = resolveNodeValue(node.directives.visible, node, renderVariables);
7529
+ if (resolved === false
7530
+ || resolved === 'false'
7531
+ || resolved === ''
7532
+ || resolved === 0) {
7533
+ const placeholder = document.createElement('div');
7534
+ placeholder.style.display = 'none';
7535
+ placeholder.setAttribute('data-card-id', node.id);
7536
+ return placeholder;
7537
+ }
7538
+ }
7539
+ const resolvedProps = resolveNodeProps(node, renderVariables);
7540
+ let isDisabled = false;
7541
+ if (node.directives?.disabled) {
7542
+ const resolved = resolveNodeValue(node.directives.disabled, node, renderVariables);
7543
+ isDisabled = (resolved === true
7544
+ || resolved === 'true'
7545
+ || resolved === 1);
7546
+ }
7547
+ const renderer = (componentRenderers[node.type] ?? componentRenderers._default);
7548
+ const el = renderer(node, resolvedProps, isMobile);
7549
+ if (isDisabled) {
7550
+ el.setAttribute('data-disabled', 'true');
7551
+ el.style.background = '#F5F5F5';
7552
+ el.style.color = '#C0C0C0';
7553
+ el.style.setProperty('--card-disabled-color', '#C0C0C0');
7554
+ el.style.pointerEvents = 'none';
7555
+ el.style.cursor = 'default';
7556
+ }
7557
+ const variableKey = resolvedProps.variableKey;
7558
+ if (variableKey) {
7559
+ el.addEventListener('input', ((event) => {
7560
+ if (event.target !== el || disposed)
7561
+ return;
7562
+ const value = event.detail?.value
7563
+ ?? event.target?.value;
7564
+ if (value !== undefined) {
7565
+ // Keep the existing focus-preserving, no-rerender behavior. Bumping
7566
+ // the revision prevents an older async action from overwriting it.
7567
+ variables[variableKey] = value;
7568
+ revision += 1;
7569
+ }
7570
+ }));
7571
+ }
7572
+ if (node.events && !isDisabled) {
7573
+ for (const [eventName, eventValue] of Object.entries(node.events)) {
7574
+ if (!eventValue || !resolveActionRef(eventValue, schemaActions)) {
7575
+ continue;
7576
+ }
7577
+ const domEvent = EVENT_MAP[eventName] ?? eventName;
7578
+ el.addEventListener(domEvent, ((event) => {
7579
+ const ownsValueEvent = ((domEvent === 'input' || domEvent === 'change')
7580
+ && VALUE_CONTROL_TYPES$1.has(node.type));
7581
+ if (ownsValueEvent && event.target !== el)
7582
+ return;
7583
+ const eventDetail = (event instanceof CustomEvent && event.detail != null)
7584
+ ? event.detail
7585
+ : undefined;
7586
+ enqueueBoundEvent(node.id, eventName, eventDetail);
7587
+ }));
7588
+ }
7589
+ }
7590
+ if (node.lifecycle) {
7591
+ lifecycleIds.add(node.id);
7592
+ }
7593
+ const renderChild = (child) => renderNode(child, renderVariables, lifecycleIds);
7594
+ const childrenMap = {};
7595
+ for (const child of node.children) {
7596
+ childrenMap[child.id] = child;
7597
+ }
7598
+ const layoutApplied = renderSlotLayout(el, node.children, resolvedProps, child => renderChild(child), childrenMap, createPassiveActionContext(renderVariables));
7599
+ if (!layoutApplied) {
7600
+ for (const child of node.children) {
7601
+ el.appendChild(renderChild(child));
7602
+ }
7603
+ }
7604
+ if (isDisabled) {
7605
+ el.querySelectorAll('*').forEach((child) => {
7606
+ const htmlChild = child;
7607
+ htmlChild.setAttribute('data-disabled', 'true');
7608
+ htmlChild.style.setProperty('--card-disabled-color', '#C0C0C0');
7609
+ });
7610
+ }
7611
+ return el;
7612
+ }
7613
+ function prepareCandidate(draft) {
7614
+ const materialized = materializeCard(schema, draft);
7615
+ const lifecycleIds = new Set();
7616
+ const dom = renderNode(materialized.root, draft, lifecycleIds);
7617
+ return { materialized, dom, lifecycleIds };
7618
+ }
7619
+ function indexNodes(root) {
7620
+ const nodes = new Map();
7621
+ const visit = (node) => {
7622
+ nodes.set(node.id, node);
7623
+ node.children.forEach(visit);
7624
+ };
7625
+ visit(root);
7626
+ return nodes;
7627
+ }
7628
+ function selectLifecycleNodes(materialized, ids) {
7629
+ const indexed = indexNodes(materialized.root);
7630
+ const selected = new Map();
7631
+ for (const id of ids) {
7632
+ const node = indexed.get(id);
7633
+ if (node)
7634
+ selected.set(id, node);
7635
+ }
7636
+ return selected;
7637
+ }
7638
+ function createLifecycleActionContext(node) {
7639
+ const writeLiveVariable = (key, value) => {
7640
+ if (disposed)
7641
+ return;
7642
+ updateVariables({ [key]: value });
7643
+ };
7644
+ return {
7645
+ ...createWebActionContext({
7646
+ ...options,
7647
+ setVariable: writeLiveVariable,
7648
+ abortSignal: abortController.signal,
7649
+ }),
7650
+ variables,
7651
+ expressionContext: createExpressionContext(node.scope),
7652
+ parameterResolver: node.bindingDialect === 'a2ui'
7653
+ ? createA2UIParameterResolver(variables, node.dataPath)
7654
+ : undefined,
7655
+ variableWriter: (key, value) => writeLiveVariable(key, value),
7656
+ botId: options.botId,
7657
+ inflightRequests,
7658
+ };
7659
+ }
7660
+ function reconcileLifecycles(nextNodes) {
7661
+ const removed = [...activeLifecycleNodes.entries()]
7662
+ .filter(([id]) => !nextNodes.has(id));
7663
+ const added = [...nextNodes.entries()]
7664
+ .filter(([id]) => !activeLifecycleNodes.has(id));
7665
+ activeLifecycleNodes.clear();
7666
+ for (const [id, node] of nextNodes) {
7667
+ activeLifecycleNodes.set(id, node);
7668
+ }
7669
+ lifecycleQueue = lifecycleQueue.then(async () => {
7670
+ for (const [id, node] of removed) {
7671
+ await lifecycleManager.destroy(id, createLifecycleActionContext(node));
7672
+ lifecycleManager.unregister(id);
7673
+ }
7674
+ if (disposed)
7675
+ return;
7676
+ for (const [id, node] of added) {
7677
+ lifecycleManager.register(id, node.lifecycle);
7678
+ await lifecycleManager.mount(id, createLifecycleActionContext(node));
7679
+ }
7680
+ }).catch((error) => {
7681
+ console.error('[renderCard] Bound lifecycle action failed', error);
7682
+ });
7683
+ }
7684
+ function publishDOM(candidate, incrementRevision) {
7685
+ const scrollPositions = captureScrollPositions$1(container);
7686
+ const mediaStates = captureMediaStates$1(container);
7687
+ disposeChartsIn(container);
7688
+ container.replaceChildren(candidate.dom);
7689
+ currentMaterialized = candidate.materialized;
7690
+ if (incrementRevision)
7691
+ revision += 1;
7692
+ reconcileLifecycles(selectLifecycleNodes(currentMaterialized, candidate.lifecycleIds));
7693
+ restoreScrollPositions$1(container, scrollPositions);
7694
+ restoreMediaStates$1(container, mediaStates);
7695
+ }
7696
+ function assertCurrentRevision(baseRevision) {
7697
+ if (disposed || revision !== baseRevision) {
7698
+ const error = new Error('[renderCard] BOUND_TRANSACTION_CONFLICT');
7699
+ error.code = 'BOUND_TRANSACTION_CONFLICT';
7700
+ throw error;
7701
+ }
7702
+ }
7703
+ function commitDraft(draft, candidate, baseRevision) {
7704
+ assertCurrentRevision(baseRevision);
7705
+ replaceRootContents(variables, draft);
7706
+ // Re-materialize against the stable public variables root. The detached
7707
+ // DOM already represents the exact same validated JSON data.
7708
+ candidate.materialized = materializeCard(schema, variables);
7709
+ publishDOM(candidate, true);
7710
+ }
7711
+ function writeDraftVariable(draft, key, value) {
7712
+ Object.defineProperty(draft, String(key), {
7713
+ value,
7714
+ enumerable: true,
7715
+ configurable: true,
7716
+ writable: true,
7717
+ });
7718
+ }
7719
+ function createDraftActionContext(node, draft) {
7720
+ const write = (key, value) => {
7721
+ writeDraftVariable(draft, key, value);
7722
+ };
7723
+ return {
7724
+ ...createWebActionContext({
7725
+ ...options,
7726
+ setVariable: write,
7727
+ abortSignal: abortController.signal,
7728
+ }),
7729
+ variables: draft,
7730
+ expressionContext: createExpressionContext(node.scope),
7731
+ parameterResolver: node.bindingDialect === 'a2ui'
7732
+ ? createA2UIParameterResolver(draft, node.dataPath)
7733
+ : undefined,
7734
+ variableWriter: (key, value) => write(key, value),
7735
+ botId: options.botId,
7736
+ inflightRequests,
7737
+ };
7738
+ }
7739
+ async function runBoundEvent(runtimeId, eventName, eventDetail) {
7740
+ if (disposed)
7741
+ return;
7742
+ const baseRevision = revision;
7743
+ const draft = cloneJsonData(variables);
7744
+ if (eventDetail !== undefined) {
7745
+ writeDraftVariable(draft, '_event', cloneJsonData(eventDetail));
7746
+ }
7747
+ const freshMaterialized = materializeCard(schema, draft);
7748
+ const freshNode = indexNodes(freshMaterialized.root).get(runtimeId);
7749
+ if (!freshNode) {
7750
+ throw new Error(`[renderCard] Bound runtime node "${runtimeId}" no longer exists`);
7751
+ }
7752
+ const eventValue = freshNode.events?.[eventName];
7753
+ const steps = eventValue
7754
+ ? resolveActionRef(eventValue, schemaActions)
7755
+ : undefined;
7756
+ if (!steps)
7757
+ return;
7758
+ await runActionSteps(steps, createDraftActionContext(freshNode, draft));
7759
+ assertCurrentRevision(baseRevision);
7760
+ const candidate = prepareCandidate(draft);
7761
+ commitDraft(draft, candidate, baseRevision);
7762
+ }
7763
+ function enqueueBoundEvent(runtimeId, eventName, eventDetail) {
7764
+ if (disposed)
7765
+ return;
7766
+ actionQueue = actionQueue
7767
+ .then(() => runBoundEvent(runtimeId, eventName, eventDetail))
7768
+ .catch((error) => {
7769
+ if (error
7770
+ && typeof error === 'object'
7771
+ && error.code
7772
+ === 'BOUND_TRANSACTION_CONFLICT') {
7773
+ return;
7774
+ }
7775
+ console.error('[renderCard] Bound action failed', error);
7776
+ });
7777
+ }
7778
+ function updateVariables(newVariables) {
7779
+ if (disposed)
7780
+ return;
7781
+ const baseRevision = revision;
7782
+ const draft = cloneJsonData(variables);
7783
+ const patch = cloneJsonData(newVariables);
7784
+ for (const key of Object.keys(patch)) {
7785
+ writeDraftVariable(draft, key, patch[key]);
7786
+ }
7787
+ const candidate = prepareCandidate(draft);
7788
+ commitDraft(draft, candidate, baseRevision);
7789
+ }
7790
+ const initialCandidate = prepareCandidate(variables);
7791
+ currentMaterialized = initialCandidate.materialized;
7792
+ publishDOM(initialCandidate, false);
7793
+ return {
7794
+ dispose() {
7795
+ if (disposed)
7796
+ return;
7797
+ disposed = true;
7798
+ abortController.abort();
7799
+ removeViewportListener();
7800
+ disposeChartsIn(container);
7801
+ container.replaceChildren();
7802
+ const lifecycleNodes = [...activeLifecycleNodes.entries()];
7803
+ activeLifecycleNodes.clear();
7804
+ lifecycleQueue = lifecycleQueue
7805
+ .then(async () => {
7806
+ for (const [id, node] of lifecycleNodes) {
7807
+ await lifecycleManager.destroy(id, createLifecycleActionContext(node));
7808
+ lifecycleManager.unregister(id);
7809
+ }
7810
+ await lifecycleManager.dispose(createPassiveActionContext(variables));
7811
+ })
7812
+ .catch((error) => {
7813
+ console.error('[renderCard] Bound lifecycle dispose failed', error);
7814
+ });
7815
+ },
7816
+ updateVariables,
7817
+ };
7818
+ }
7819
+ function captureScrollPositions$1(root) {
7820
+ const positions = new Map();
7821
+ root.querySelectorAll('[data-scroll-id]').forEach((element) => {
7822
+ const id = element.getAttribute('data-scroll-id');
7823
+ if (id && element.scrollLeft > 0) {
7824
+ positions.set(id, element.scrollLeft);
7825
+ }
7826
+ });
7827
+ return positions;
7828
+ }
7829
+ function restoreScrollPositions$1(root, positions) {
7830
+ if (positions.size === 0)
7831
+ return;
7832
+ root.querySelectorAll('[data-scroll-id]').forEach((element) => {
7833
+ const id = element.getAttribute('data-scroll-id');
7834
+ const saved = id ? positions.get(id) : undefined;
7835
+ if (saved == null)
7836
+ return;
7837
+ const scroller = element;
7838
+ scroller.dataset.restoreScrollLeft = String(saved);
7839
+ const previousSnap = scroller.style.scrollSnapType;
7840
+ scroller.style.scrollSnapType = 'none';
7841
+ scroller.scrollLeft = saved;
7842
+ requestAnimationFrame(() => {
7843
+ scroller.style.scrollSnapType = previousSnap;
7844
+ });
7845
+ });
7846
+ }
7847
+ function captureMediaStates$1(root) {
7848
+ const states = new Map();
7849
+ root.querySelectorAll('ai-card-audio, ai-card-video').forEach((element) => {
7850
+ const id = element.getAttribute('data-card-id');
7851
+ const media = element.shadowRoot?.querySelector('audio, video');
7852
+ const snapshot = snapshotMedia(media);
7853
+ if (id && snapshot)
7854
+ states.set(id, snapshot);
7855
+ });
7856
+ return states;
7857
+ }
7858
+ function restoreMediaStates$1(root, states) {
7859
+ if (states.size === 0)
7860
+ return;
7861
+ root.querySelectorAll('ai-card-audio, ai-card-video').forEach((element) => {
7862
+ const id = element.getAttribute('data-card-id');
7863
+ const snapshot = id ? states.get(id) : undefined;
7864
+ if (!snapshot)
7865
+ return;
7866
+ const media = element.shadowRoot?.querySelector('audio, video');
7867
+ restoreMedia(media, snapshot);
7868
+ });
7869
+ }
7870
+ const EVENT_MAP = {
7871
+ onClick: 'click',
7872
+ onChange: 'change',
7873
+ onInput: 'input',
7874
+ onSubmit: 'submit',
7875
+ onStepClick: 'step-click',
7876
+ onPlay: 'play',
7877
+ onPause: 'pause',
7878
+ onEnded: 'ended',
7879
+ onMaxReached: 'max-reached',
7880
+ };
7881
+ const VALUE_CONTROL_TYPES$1 = new Set([
7882
+ 'ChoiceList',
7883
+ 'Counter',
7884
+ 'Input',
7885
+ 'Rate',
7886
+ 'Select',
7887
+ 'Switch',
7888
+ ]);
7889
+
5664
7890
  /**
5665
7891
  * renderCard — the primary public API for @antglobal/copilot-cards-web.
5666
7892
  *
@@ -5688,6 +7914,12 @@ function renderCard(container, schemaInput, options = {}) {
5688
7914
  if (errors.length > 0) {
5689
7915
  throw new Error(`[renderCard] Invalid schema:\n${errors.join('\n')}`);
5690
7916
  }
7917
+ if (!requiresBindingMaterialization(schema)) {
7918
+ return renderStaticCard(container, schema, options);
7919
+ }
7920
+ return renderBoundCard(container, schema, options);
7921
+ }
7922
+ function renderStaticCard(container, schema, options) {
5691
7923
  // 2. Parse into render tree
5692
7924
  const tree = parseSchema(schema);
5693
7925
  // 3. Reactive variables store (mutable copy, merged with external variables)
@@ -5878,6 +8110,10 @@ function renderNode(node, variables, actionContext, isMobile, lifecycleManager,
5878
8110
  const variableKey = resolvedProps.variableKey;
5879
8111
  if (variableKey) {
5880
8112
  el.addEventListener('input', ((e) => {
8113
+ // Nested value controls own their variableKey. Only a value event
8114
+ // dispatched by this component may update this component's variable.
8115
+ if (e.target !== el)
8116
+ return;
5881
8117
  const value = e.detail?.value
5882
8118
  ?? e.target?.value;
5883
8119
  if (value !== undefined) {
@@ -5896,6 +8132,14 @@ function renderNode(node, variables, actionContext, isMobile, lifecycleManager,
5896
8132
  continue;
5897
8133
  const domEvent = eventMap[event] ?? event;
5898
8134
  el.addEventListener(domEvent, ((e) => {
8135
+ // Value controls own their input/change handlers; a nested control's
8136
+ // same-named event must not trigger the parent's schema action.
8137
+ // Other component/event pairs keep the renderer's existing bubbling
8138
+ // behavior (for example a Container-level click handler).
8139
+ const ownsValueEvent = ((domEvent === 'input' || domEvent === 'change')
8140
+ && VALUE_CONTROL_TYPES.has(node.type));
8141
+ if (ownsValueEvent && e.target !== el)
8142
+ return;
5899
8143
  // Write event.detail to _event variable so action params can use ${_event.xxx}
5900
8144
  if (e instanceof CustomEvent && e.detail != null) {
5901
8145
  variables._event = e.detail;
@@ -5943,7 +8187,16 @@ const eventMap = {
5943
8187
  onPlay: 'play',
5944
8188
  onPause: 'pause',
5945
8189
  onEnded: 'ended',
8190
+ onMaxReached: 'max-reached',
5946
8191
  };
8192
+ const VALUE_CONTROL_TYPES = new Set([
8193
+ 'ChoiceList',
8194
+ 'Counter',
8195
+ 'Input',
8196
+ 'Rate',
8197
+ 'Select',
8198
+ 'Switch',
8199
+ ]);
5947
8200
 
5948
8201
  /**
5949
8202
  * Streaming Card Renderer — enables progressive card rendering via streaming commands.
@@ -5999,6 +8252,15 @@ function renderStreamingCard(container, options = {}) {
5999
8252
  let isMobile = options.isMobile ?? isMobileViewport();
6000
8253
  let currentSchema = null;
6001
8254
  let currentSurfaceId = null;
8255
+ let currentMaterialized = null;
8256
+ let boundRevision = 0;
8257
+ let boundActionQueue = Promise.resolve();
8258
+ let boundLifecycleEpoch = 0;
8259
+ let disposed = false;
8260
+ const sourceOccurrences = new Map();
8261
+ const activeBoundLifecycles = new Map();
8262
+ const mountedBoundLifecycles = new Map();
8263
+ const boundLifecycleGenerations = new Map();
6002
8264
  // Responsive viewport detection
6003
8265
  const removeViewportListener = options.isMobile == null
6004
8266
  ? onViewportChange((mobile) => {
@@ -6007,35 +8269,200 @@ function renderStreamingCard(container, options = {}) {
6007
8269
  if (currentSchema) {
6008
8270
  rerenderAll();
6009
8271
  }
6010
- })
6011
- : () => { };
6012
- // ─── Action Context ─────────────────────────────────────────────
6013
- function buildActionContext() {
6014
- return {
6015
- ...createWebActionContext({
6016
- ...options,
6017
- setVariable: (key, value) => {
6018
- variables[key] = value;
6019
- // On variable change, patch only affected elements (keyed diff)
6020
- diffAllElements();
6021
- },
6022
- abortSignal: abortController.signal,
6023
- }),
6024
- variables,
6025
- botId: options.botId,
6026
- inflightRequests,
6027
- };
8272
+ })
8273
+ : () => { };
8274
+ // ─── Action Context ─────────────────────────────────────────────
8275
+ function buildActionContext() {
8276
+ return {
8277
+ ...createWebActionContext({
8278
+ ...options,
8279
+ setVariable: (key, value) => {
8280
+ variables[key] = value;
8281
+ // On variable change, patch only affected elements (keyed diff)
8282
+ diffAllElements();
8283
+ },
8284
+ abortSignal: abortController.signal,
8285
+ }),
8286
+ variables,
8287
+ botId: options.botId,
8288
+ inflightRequests,
8289
+ };
8290
+ }
8291
+ let actionContext = buildActionContext();
8292
+ // ─── Rendering Helpers ──────────────────────────────────────────
8293
+ /** Event name map: schema events → DOM events */
8294
+ const eventMap = {
8295
+ onClick: 'click',
8296
+ onChange: 'change',
8297
+ onInput: 'input',
8298
+ onSubmit: 'submit',
8299
+ onStepClick: 'step-click',
8300
+ onPlay: 'play',
8301
+ onPause: 'pause',
8302
+ onEnded: 'ended',
8303
+ onMaxReached: 'max-reached',
8304
+ };
8305
+ const valueControlTypes = new Set([
8306
+ 'ChoiceList',
8307
+ 'Counter',
8308
+ 'Input',
8309
+ 'Rate',
8310
+ 'Select',
8311
+ 'Switch',
8312
+ ]);
8313
+ function createBoundIndexes() {
8314
+ return {
8315
+ elements: new Map(),
8316
+ props: new Map(),
8317
+ hidden: new Set(),
8318
+ sources: new Map(),
8319
+ };
8320
+ }
8321
+ function addSourceOccurrence(indexes, node) {
8322
+ const occurrences = indexes.sources.get(node.sourceId);
8323
+ if (occurrences) {
8324
+ occurrences.add(node.id);
8325
+ }
8326
+ else {
8327
+ indexes.sources.set(node.sourceId, new Set([node.id]));
8328
+ }
8329
+ }
8330
+ function boundExpressionContext(node) {
8331
+ return isBoundRenderTreeNode(node)
8332
+ ? createExpressionContext(node.scope)
8333
+ : variables;
8334
+ }
8335
+ function resolveBoundValue(value, node, renderVariables) {
8336
+ if (node.bindingDialect === 'a2ui') {
8337
+ return resolveA2UIDeep(value, renderVariables, node.dataPath);
8338
+ }
8339
+ if (typeof value === 'string' && hasExpression(value)) {
8340
+ return resolveExpression(value, boundExpressionContext(node));
8341
+ }
8342
+ return value;
8343
+ }
8344
+ function resolveBoundNodeProps(node, renderVariables) {
8345
+ const resolved = (node.bindingDialect === 'a2ui'
8346
+ ? resolveA2UIDeep(node.props, renderVariables, node.dataPath)
8347
+ : resolveDeep(node.props, boundExpressionContext(node)));
8348
+ if (resolved.content
8349
+ && typeof resolved.content === 'object'
8350
+ && 'type' in resolved.content) {
8351
+ resolved.content = resolveExpressionValue(resolved.content, boundExpressionContext(node));
8352
+ }
8353
+ return resolved;
8354
+ }
8355
+ function computeBoundVisible(node, renderVariables) {
8356
+ const visible = node.directives?.visible;
8357
+ if (!visible)
8358
+ return true;
8359
+ const resolved = resolveBoundValue(visible, node, renderVariables);
8360
+ return !(resolved === false
8361
+ || resolved === 'false'
8362
+ || resolved === ''
8363
+ || resolved === 0);
8364
+ }
8365
+ function computeBoundDisabled(node, renderVariables) {
8366
+ const disabled = node.directives?.disabled;
8367
+ if (!disabled)
8368
+ return false;
8369
+ const resolved = resolveBoundValue(disabled, node, renderVariables);
8370
+ return resolved === true || resolved === 'true' || resolved === 1;
8371
+ }
8372
+ function boundNodeFingerprint(node, renderVariables, resolvedProps = resolveBoundNodeProps(node, renderVariables)) {
8373
+ return fingerprint(resolvedProps, {
8374
+ visible: computeBoundVisible(node, renderVariables),
8375
+ disabled: computeBoundDisabled(node, renderVariables),
8376
+ });
8377
+ }
8378
+ function writeLiveBoundVariable(key, value) {
8379
+ Object.defineProperty(variables, String(key), {
8380
+ value,
8381
+ enumerable: true,
8382
+ configurable: true,
8383
+ writable: true,
8384
+ });
8385
+ const schema = currentSurfaceId
8386
+ ? engine.getSchema(currentSurfaceId)
8387
+ : currentSchema;
8388
+ if (schema) {
8389
+ Object.defineProperty(schema.variables, String(key), {
8390
+ value: cloneJsonData(value),
8391
+ enumerable: true,
8392
+ configurable: true,
8393
+ writable: true,
8394
+ });
8395
+ }
8396
+ boundRevision += 1;
8397
+ }
8398
+ function renderBoundNode(node, schema, renderVariables, indexes, lifecycleCollector) {
8399
+ addSourceOccurrence(indexes, node);
8400
+ if (!computeBoundVisible(node, renderVariables)) {
8401
+ const placeholder = document.createElement('div');
8402
+ placeholder.style.display = 'none';
8403
+ placeholder.setAttribute('data-card-id', node.id);
8404
+ indexes.elements.set(node.id, placeholder);
8405
+ indexes.hidden.add(node.id);
8406
+ return placeholder;
8407
+ }
8408
+ const resolvedProps = resolveBoundNodeProps(node, renderVariables);
8409
+ indexes.hidden.delete(node.id);
8410
+ indexes.props.set(node.id, boundNodeFingerprint(node, renderVariables, resolvedProps));
8411
+ const isDisabled = computeBoundDisabled(node, renderVariables);
8412
+ const renderer = (componentRenderers[node.type] ?? componentRenderers._default);
8413
+ const element = renderer(node, resolvedProps, isMobile);
8414
+ if (isDisabled) {
8415
+ element.setAttribute('data-disabled', 'true');
8416
+ element.style.background = '#F5F5F5';
8417
+ element.style.color = '#C0C0C0';
8418
+ element.style.setProperty('--card-disabled-color', '#C0C0C0');
8419
+ element.style.pointerEvents = 'none';
8420
+ }
8421
+ const variableKey = resolvedProps.variableKey;
8422
+ if (variableKey) {
8423
+ element.addEventListener('input', ((event) => {
8424
+ if (event.target !== element || !currentMaterialized)
8425
+ return;
8426
+ const value = event.detail?.value
8427
+ ?? event.target?.value;
8428
+ if (value !== undefined)
8429
+ writeLiveBoundVariable(variableKey, value);
8430
+ }));
8431
+ }
8432
+ if (node.events && !isDisabled) {
8433
+ for (const [eventName, eventValue] of Object.entries(node.events)) {
8434
+ if (!eventValue || !resolveActionRef(eventValue, schema.actions ?? {})) {
8435
+ continue;
8436
+ }
8437
+ const domEvent = eventMap[eventName] ?? eventName;
8438
+ element.addEventListener(domEvent, ((event) => {
8439
+ const ownsValueEvent = ((domEvent === 'input' || domEvent === 'change')
8440
+ && valueControlTypes.has(node.type));
8441
+ if (ownsValueEvent && event.target !== element)
8442
+ return;
8443
+ const detail = (event instanceof CustomEvent && event.detail != null)
8444
+ ? event.detail
8445
+ : undefined;
8446
+ enqueueBoundEvent(node.id, eventName, detail);
8447
+ }));
8448
+ }
8449
+ }
8450
+ if (node.lifecycle) {
8451
+ lifecycleCollector.set(node.id, node);
8452
+ }
8453
+ indexes.elements.set(node.id, element);
8454
+ const renderChild = (child) => renderBoundNode(child, schema, renderVariables, indexes, lifecycleCollector);
8455
+ const childrenMap = {};
8456
+ for (const child of node.children)
8457
+ childrenMap[child.id] = child;
8458
+ const layoutApplied = renderSlotLayout(element, node.children, resolvedProps, child => renderChild(child), childrenMap, actionContext);
8459
+ if (!layoutApplied) {
8460
+ for (const child of node.children) {
8461
+ element.appendChild(renderChild(child));
8462
+ }
8463
+ }
8464
+ return element;
6028
8465
  }
6029
- let actionContext = buildActionContext();
6030
- // ─── Rendering Helpers ──────────────────────────────────────────
6031
- /** Event name map: schema events → DOM events */
6032
- const eventMap = {
6033
- onClick: 'click',
6034
- onChange: 'change',
6035
- onInput: 'input',
6036
- onSubmit: 'submit',
6037
- onStepClick: 'step-click',
6038
- };
6039
8466
  /**
6040
8467
  * Render a single node and register it in elementMap.
6041
8468
  */
@@ -6271,6 +8698,540 @@ function renderStreamingCard(container, options = {}) {
6271
8698
  }
6272
8699
  }
6273
8700
  }
8701
+ function captureBoundScrollPositions(root) {
8702
+ const positions = new Map();
8703
+ root.querySelectorAll('[data-scroll-id]').forEach((element) => {
8704
+ const id = element.getAttribute('data-scroll-id');
8705
+ if (id && element.scrollLeft > 0) {
8706
+ positions.set(id, element.scrollLeft);
8707
+ }
8708
+ });
8709
+ return positions;
8710
+ }
8711
+ function restoreBoundScrollPositions(root, positions) {
8712
+ if (positions.size === 0)
8713
+ return;
8714
+ root.querySelectorAll('[data-scroll-id]').forEach((element) => {
8715
+ const id = element.getAttribute('data-scroll-id');
8716
+ const saved = id ? positions.get(id) : undefined;
8717
+ if (saved == null)
8718
+ return;
8719
+ const scroller = element;
8720
+ scroller.dataset.restoreScrollLeft = String(saved);
8721
+ const previousSnap = scroller.style.scrollSnapType;
8722
+ scroller.style.scrollSnapType = 'none';
8723
+ scroller.scrollLeft = saved;
8724
+ requestAnimationFrame(() => {
8725
+ if (scroller.isConnected) {
8726
+ scroller.style.scrollSnapType = previousSnap;
8727
+ }
8728
+ });
8729
+ });
8730
+ }
8731
+ function captureBoundMediaStates(root) {
8732
+ const states = new Map();
8733
+ root.querySelectorAll('ai-card-audio, ai-card-video')
8734
+ .forEach((element) => {
8735
+ const id = element.getAttribute('data-card-id');
8736
+ const media = element.shadowRoot?.querySelector('audio, video');
8737
+ const snapshot = snapshotMedia(media);
8738
+ if (id && snapshot)
8739
+ states.set(id, snapshot);
8740
+ });
8741
+ return states;
8742
+ }
8743
+ function restoreBoundMediaStates(root, states) {
8744
+ if (states.size === 0)
8745
+ return;
8746
+ root.querySelectorAll('ai-card-audio, ai-card-video')
8747
+ .forEach((element) => {
8748
+ const id = element.getAttribute('data-card-id');
8749
+ const snapshot = id ? states.get(id) : undefined;
8750
+ if (!snapshot)
8751
+ return;
8752
+ const media = element.shadowRoot?.querySelector('audio, video');
8753
+ restoreMedia(media, snapshot);
8754
+ });
8755
+ }
8756
+ class IncompleteBoundSchemaError extends Error {
8757
+ constructor() {
8758
+ super(...arguments);
8759
+ this.code = 'INCOMPLETE_BOUND_SCHEMA';
8760
+ }
8761
+ }
8762
+ class InvalidBoundSchemaError extends Error {
8763
+ constructor() {
8764
+ super(...arguments);
8765
+ this.code = 'INVALID_BOUND_SCHEMA';
8766
+ }
8767
+ }
8768
+ class BoundPatchPreconditionError extends Error {
8769
+ constructor() {
8770
+ super(...arguments);
8771
+ this.code = 'BOUND_PATCH_PRECONDITION';
8772
+ }
8773
+ }
8774
+ function indexBoundNodes(root) {
8775
+ const nodes = new Map();
8776
+ const visit = (node) => {
8777
+ nodes.set(node.id, node);
8778
+ node.children.forEach(visit);
8779
+ };
8780
+ visit(root);
8781
+ return nodes;
8782
+ }
8783
+ function collectBoundNodeIds(root, output = new Set()) {
8784
+ output.add(root.id);
8785
+ for (const child of root.children)
8786
+ collectBoundNodeIds(child, output);
8787
+ return output;
8788
+ }
8789
+ function assertCompleteBoundDefinitions(schema, materialized) {
8790
+ for (const owner of materialized.repeatOwners.values()) {
8791
+ if (!schema.elements[owner.templateId]) {
8792
+ throw new IncompleteBoundSchemaError(`[renderStreamingCard] Missing repeat template "${owner.templateId}"`);
8793
+ }
8794
+ }
8795
+ }
8796
+ function materializeStreamingCard(schema, renderVariables) {
8797
+ try {
8798
+ const materialized = materializeCard(schema, renderVariables);
8799
+ assertCompleteBoundDefinitions(schema, materialized);
8800
+ const validationErrors = validateSchema(schema);
8801
+ if (validationErrors.length > 0) {
8802
+ throw new InvalidBoundSchemaError(`[renderStreamingCard] Invalid bound schema:\n${validationErrors.join('\n')}`);
8803
+ }
8804
+ return materialized;
8805
+ }
8806
+ catch (error) {
8807
+ if (error instanceof Error
8808
+ && /Missing element for id/.test(error.message)) {
8809
+ throw new IncompleteBoundSchemaError(error.message);
8810
+ }
8811
+ throw error;
8812
+ }
8813
+ }
8814
+ function selectLifecycleNodes(materialized, ids) {
8815
+ const indexed = indexBoundNodes(materialized.root);
8816
+ const selected = new Map();
8817
+ for (const id of ids) {
8818
+ const node = indexed.get(id);
8819
+ if (node)
8820
+ selected.set(id, node);
8821
+ }
8822
+ return selected;
8823
+ }
8824
+ async function runBoundLifecycleStepsAtomically(node, steps) {
8825
+ if (!steps?.length)
8826
+ return;
8827
+ const baseRevision = boundRevision;
8828
+ const before = cloneJsonData(variables);
8829
+ const draft = cloneJsonData(variables);
8830
+ await runBoundSteps(steps, node, createBoundActionContext(node, draft));
8831
+ if (topLevelChangedPaths(before, draft).length === 0)
8832
+ return;
8833
+ try {
8834
+ commitBoundDraftTransaction(before, draft, baseRevision);
8835
+ }
8836
+ catch (error) {
8837
+ if (error
8838
+ && typeof error === 'object'
8839
+ && error.code
8840
+ === 'BOUND_TRANSACTION_CONFLICT') {
8841
+ return;
8842
+ }
8843
+ throw error;
8844
+ }
8845
+ }
8846
+ function boundLifecycleFingerprint(node) {
8847
+ try {
8848
+ return JSON.stringify({
8849
+ sourceId: node.sourceId,
8850
+ lifecycle: node.lifecycle ?? null,
8851
+ dataPath: node.dataPath ?? null,
8852
+ bindingDialect: node.bindingDialect ?? null,
8853
+ });
8854
+ }
8855
+ catch {
8856
+ return `${node.id}:${node.sourceId}`;
8857
+ }
8858
+ }
8859
+ function sameBoundLifecycle(left, right) {
8860
+ return !!left
8861
+ && !!right
8862
+ && boundLifecycleFingerprint(left) === boundLifecycleFingerprint(right);
8863
+ }
8864
+ function enqueueBoundOperation(operation, failureLabel) {
8865
+ boundActionQueue = boundActionQueue
8866
+ .then(operation)
8867
+ .catch((error) => {
8868
+ if (error
8869
+ && typeof error === 'object'
8870
+ && error.code
8871
+ === 'BOUND_TRANSACTION_CONFLICT') {
8872
+ return;
8873
+ }
8874
+ console.error(failureLabel, error);
8875
+ });
8876
+ }
8877
+ async function destroyMountedBoundLifecycle(id, fallbackNode) {
8878
+ const mountedNode = mountedBoundLifecycles.get(id);
8879
+ if (!mountedNode)
8880
+ return;
8881
+ mountedBoundLifecycles.delete(id);
8882
+ const node = mountedNode ?? fallbackNode;
8883
+ await runBoundLifecycleStepsAtomically(node, node.lifecycle?.onDestroy);
8884
+ }
8885
+ async function mountDesiredBoundLifecycle(id, node, epoch, generation) {
8886
+ const desired = activeBoundLifecycles.get(id);
8887
+ if (disposed
8888
+ || epoch !== boundLifecycleEpoch
8889
+ || boundLifecycleGenerations.get(id) !== generation
8890
+ || !sameBoundLifecycle(desired, node)) {
8891
+ return;
8892
+ }
8893
+ await runBoundLifecycleStepsAtomically(node, node.lifecycle?.onMount);
8894
+ const stillDesired = activeBoundLifecycles.get(id);
8895
+ if (disposed
8896
+ || epoch !== boundLifecycleEpoch
8897
+ || boundLifecycleGenerations.get(id) !== generation
8898
+ || !sameBoundLifecycle(stillDesired, node)) {
8899
+ await runBoundLifecycleStepsAtomically(node, node.lifecycle?.onDestroy);
8900
+ return;
8901
+ }
8902
+ mountedBoundLifecycles.set(id, node);
8903
+ }
8904
+ function reconcileBoundLifecycles(next, forceReplaceIds = new Set()) {
8905
+ const previous = new Map(activeBoundLifecycles);
8906
+ const replacedIds = new Set(forceReplaceIds);
8907
+ for (const [id, nextNode] of next) {
8908
+ const previousNode = previous.get(id);
8909
+ if (previousNode
8910
+ && !sameBoundLifecycle(previousNode, nextNode)) {
8911
+ replacedIds.add(id);
8912
+ }
8913
+ }
8914
+ const removed = [...previous.entries()]
8915
+ .filter(([id]) => !next.has(id) || replacedIds.has(id));
8916
+ const added = [...next.entries()]
8917
+ .filter(([id]) => !previous.has(id) || replacedIds.has(id));
8918
+ const touchedGenerations = new Map();
8919
+ for (const id of new Set([
8920
+ ...removed.map(([id]) => id),
8921
+ ...added.map(([id]) => id),
8922
+ ])) {
8923
+ const generation = (boundLifecycleGenerations.get(id) ?? 0) + 1;
8924
+ boundLifecycleGenerations.set(id, generation);
8925
+ touchedGenerations.set(id, generation);
8926
+ }
8927
+ activeBoundLifecycles.clear();
8928
+ for (const [id, node] of next)
8929
+ activeBoundLifecycles.set(id, node);
8930
+ const epoch = boundLifecycleEpoch;
8931
+ enqueueBoundOperation(async () => {
8932
+ for (const [id, node] of removed) {
8933
+ await destroyMountedBoundLifecycle(id, node);
8934
+ }
8935
+ for (const [id, node] of added) {
8936
+ const generation = touchedGenerations.get(id);
8937
+ if (generation != null) {
8938
+ await mountDesiredBoundLifecycle(id, node, epoch, generation);
8939
+ }
8940
+ }
8941
+ for (const [id] of removed) {
8942
+ const generation = touchedGenerations.get(id);
8943
+ if (generation != null
8944
+ && boundLifecycleGenerations.get(id) === generation
8945
+ && !activeBoundLifecycles.has(id)
8946
+ && !mountedBoundLifecycles.has(id)) {
8947
+ boundLifecycleGenerations.delete(id);
8948
+ }
8949
+ }
8950
+ }, '[renderStreamingCard] Bound lifecycle action failed');
8951
+ }
8952
+ function teardownBoundLifecycles() {
8953
+ if (activeBoundLifecycles.size === 0
8954
+ && mountedBoundLifecycles.size === 0) {
8955
+ boundLifecycleEpoch += 1;
8956
+ return;
8957
+ }
8958
+ const nodes = new Map(activeBoundLifecycles);
8959
+ const invalidatedGenerations = new Map();
8960
+ for (const id of new Set([
8961
+ ...activeBoundLifecycles.keys(),
8962
+ ...mountedBoundLifecycles.keys(),
8963
+ ])) {
8964
+ const generation = (boundLifecycleGenerations.get(id) ?? 0) + 1;
8965
+ boundLifecycleGenerations.set(id, generation);
8966
+ invalidatedGenerations.set(id, generation);
8967
+ }
8968
+ activeBoundLifecycles.clear();
8969
+ boundLifecycleEpoch += 1;
8970
+ enqueueBoundOperation(async () => {
8971
+ const ids = new Set([
8972
+ ...nodes.keys(),
8973
+ ...mountedBoundLifecycles.keys(),
8974
+ ]);
8975
+ for (const id of ids) {
8976
+ await destroyMountedBoundLifecycle(id, nodes.get(id));
8977
+ if (boundLifecycleGenerations.get(id)
8978
+ === invalidatedGenerations.get(id)
8979
+ && !activeBoundLifecycles.has(id)
8980
+ && !mountedBoundLifecycles.has(id)) {
8981
+ boundLifecycleGenerations.delete(id);
8982
+ }
8983
+ }
8984
+ }, '[renderStreamingCard] Bound lifecycle dispose failed');
8985
+ }
8986
+ function replaceProductionIndexes(indexes) {
8987
+ elementMap.clear();
8988
+ propsCache.clear();
8989
+ hiddenSet.clear();
8990
+ sourceOccurrences.clear();
8991
+ for (const [id, element] of indexes.elements) {
8992
+ elementMap.set(id, element);
8993
+ }
8994
+ for (const [id, value] of indexes.props)
8995
+ propsCache.set(id, value);
8996
+ for (const id of indexes.hidden)
8997
+ hiddenSet.add(id);
8998
+ for (const [sourceId, ids] of indexes.sources) {
8999
+ sourceOccurrences.set(sourceId, new Set(ids));
9000
+ }
9001
+ }
9002
+ function mergeProductionIndexes(indexes) {
9003
+ for (const [id, element] of indexes.elements) {
9004
+ elementMap.set(id, element);
9005
+ }
9006
+ for (const [id, value] of indexes.props)
9007
+ propsCache.set(id, value);
9008
+ for (const id of indexes.hidden)
9009
+ hiddenSet.add(id);
9010
+ for (const [sourceId, ids] of indexes.sources) {
9011
+ const occurrences = sourceOccurrences.get(sourceId)
9012
+ ?? new Set();
9013
+ for (const id of ids)
9014
+ occurrences.add(id);
9015
+ sourceOccurrences.set(sourceId, occurrences);
9016
+ }
9017
+ }
9018
+ function unindexBoundSubtree(node) {
9019
+ const visit = (current) => {
9020
+ elementMap.delete(current.id);
9021
+ propsCache.delete(current.id);
9022
+ hiddenSet.delete(current.id);
9023
+ const occurrences = sourceOccurrences.get(current.sourceId);
9024
+ occurrences?.delete(current.id);
9025
+ if (occurrences?.size === 0) {
9026
+ sourceOccurrences.delete(current.sourceId);
9027
+ }
9028
+ current.children.forEach(visit);
9029
+ };
9030
+ visit(node);
9031
+ }
9032
+ function prepareBoundFull(schema, draft) {
9033
+ const materialized = materializeStreamingCard(schema, draft);
9034
+ const indexes = createBoundIndexes();
9035
+ const lifecycles = new Map();
9036
+ const dom = renderBoundNode(materialized.root, schema, draft, indexes, lifecycles);
9037
+ return {
9038
+ materialized,
9039
+ dom,
9040
+ indexes,
9041
+ lifecycleIds: new Set(lifecycles.keys()),
9042
+ };
9043
+ }
9044
+ function assertBoundRevision(baseRevision) {
9045
+ if (boundRevision !== baseRevision) {
9046
+ const error = new Error('[renderStreamingCard] BOUND_TRANSACTION_CONFLICT');
9047
+ error.code = 'BOUND_TRANSACTION_CONFLICT';
9048
+ throw error;
9049
+ }
9050
+ }
9051
+ function commitBoundFull(prepared, schema, draft, baseRevision, commitAuthoritativeVariables) {
9052
+ assertBoundRevision(baseRevision);
9053
+ commitAuthoritativeVariables?.();
9054
+ replaceRootContents(variables, draft);
9055
+ const stableMaterialized = materializeStreamingCard(schema, variables);
9056
+ const scrollPositions = captureBoundScrollPositions(container);
9057
+ const mediaStates = captureBoundMediaStates(container);
9058
+ disposeChartsIn(container);
9059
+ container.replaceChildren(prepared.dom);
9060
+ replaceProductionIndexes(prepared.indexes);
9061
+ currentSchema = schema;
9062
+ currentMaterialized = stableMaterialized;
9063
+ boundRevision += 1;
9064
+ actionContext = buildActionContext();
9065
+ const nextLifecycles = selectLifecycleNodes(stableMaterialized, prepared.lifecycleIds);
9066
+ reconcileBoundLifecycles(nextLifecycles, new Set([
9067
+ ...activeBoundLifecycles.keys(),
9068
+ ...nextLifecycles.keys(),
9069
+ ]));
9070
+ restoreBoundScrollPositions(container, scrollPositions);
9071
+ restoreBoundMediaStates(container, mediaStates);
9072
+ }
9073
+ function ownerKeysWithoutNestedDuplicates(materialized, owners) {
9074
+ const selected = new Set(owners.map(owner => owner.key));
9075
+ return owners.filter((owner) => {
9076
+ let parentKey = owner.parentKey;
9077
+ while (parentKey) {
9078
+ if (selected.has(parentKey))
9079
+ return false;
9080
+ parentKey = materialized.repeatOwners.get(parentKey)?.parentKey;
9081
+ }
9082
+ return true;
9083
+ });
9084
+ }
9085
+ function prepareBoundIncremental(schema, draft, requestedOwners, changedSourceIds = new Set()) {
9086
+ if (!currentMaterialized) {
9087
+ throw new BoundPatchPreconditionError('[renderStreamingCard] Bound metadata is unavailable');
9088
+ }
9089
+ const materialized = materializeStreamingCard(schema, draft);
9090
+ const oldNodes = indexBoundNodes(currentMaterialized.root);
9091
+ const nextNodes = indexBoundNodes(materialized.root);
9092
+ const owners = ownerKeysWithoutNestedDuplicates(currentMaterialized, requestedOwners);
9093
+ const ownerPatches = [];
9094
+ const coveredIds = new Set();
9095
+ const lifecycleIds = new Set(activeBoundLifecycles.keys());
9096
+ const replacedLifecycleIds = new Set();
9097
+ for (const owner of owners) {
9098
+ const oldNode = oldNodes.get(owner.runtimeOwnerId);
9099
+ const nextNode = nextNodes.get(owner.runtimeOwnerId);
9100
+ const oldElement = elementMap.get(owner.runtimeOwnerId);
9101
+ if (!oldNode
9102
+ || !nextNode
9103
+ || !oldElement
9104
+ || !oldElement.isConnected
9105
+ || !oldElement.parentNode) {
9106
+ throw new BoundPatchPreconditionError(`[renderStreamingCard] Missing repeat owner "${owner.runtimeOwnerId}"`);
9107
+ }
9108
+ const indexes = createBoundIndexes();
9109
+ const lifecycles = new Map();
9110
+ const nextElement = renderBoundNode(nextNode, schema, draft, indexes, lifecycles);
9111
+ const oldIds = collectBoundNodeIds(oldNode);
9112
+ const nextIds = collectBoundNodeIds(nextNode);
9113
+ for (const id of oldIds) {
9114
+ lifecycleIds.delete(id);
9115
+ if (activeBoundLifecycles.has(id)) {
9116
+ replacedLifecycleIds.add(id);
9117
+ }
9118
+ }
9119
+ for (const id of lifecycles.keys()) {
9120
+ lifecycleIds.add(id);
9121
+ replacedLifecycleIds.add(id);
9122
+ }
9123
+ for (const id of nextIds)
9124
+ coveredIds.add(id);
9125
+ ownerPatches.push({
9126
+ oldNode,
9127
+ oldElement,
9128
+ nextNode,
9129
+ nextElement,
9130
+ indexes,
9131
+ lifecycleIds: new Set(lifecycles.keys()),
9132
+ });
9133
+ }
9134
+ const staticPatches = [];
9135
+ for (const nextNode of nextNodes.values()) {
9136
+ if (nextNode.id !== nextNode.sourceId || coveredIds.has(nextNode.id)) {
9137
+ continue;
9138
+ }
9139
+ const oldNode = oldNodes.get(nextNode.id);
9140
+ const oldElement = elementMap.get(nextNode.id);
9141
+ if (!oldNode || !oldElement || !oldElement.isConnected)
9142
+ continue;
9143
+ const nextVisible = computeBoundVisible(nextNode, draft);
9144
+ const nextProps = nextVisible
9145
+ ? resolveBoundNodeProps(nextNode, draft)
9146
+ : undefined;
9147
+ const nextFingerprint = nextVisible
9148
+ ? boundNodeFingerprint(nextNode, draft, nextProps)
9149
+ : undefined;
9150
+ const wasHidden = hiddenSet.has(nextNode.id);
9151
+ const definitionChanged = changedSourceIds.has(nextNode.sourceId);
9152
+ if (!definitionChanged
9153
+ && ((!nextVisible && wasHidden)
9154
+ || (nextVisible && !wasHidden
9155
+ && propsCache.get(nextNode.id) === nextFingerprint))) {
9156
+ continue;
9157
+ }
9158
+ const indexes = createBoundIndexes();
9159
+ const lifecycles = new Map();
9160
+ const nextElement = renderBoundNode(nextNode, schema, draft, indexes, lifecycles);
9161
+ const oldIds = collectBoundNodeIds(oldNode);
9162
+ const nextIds = collectBoundNodeIds(nextNode);
9163
+ for (const id of oldIds) {
9164
+ lifecycleIds.delete(id);
9165
+ if (activeBoundLifecycles.has(id)) {
9166
+ replacedLifecycleIds.add(id);
9167
+ }
9168
+ }
9169
+ for (const id of lifecycles.keys()) {
9170
+ lifecycleIds.add(id);
9171
+ replacedLifecycleIds.add(id);
9172
+ }
9173
+ for (const id of nextIds)
9174
+ coveredIds.add(id);
9175
+ staticPatches.push({
9176
+ oldNode,
9177
+ oldElement,
9178
+ nextNode,
9179
+ nextElement,
9180
+ indexes,
9181
+ lifecycleIds: new Set(lifecycles.keys()),
9182
+ });
9183
+ }
9184
+ return {
9185
+ materialized,
9186
+ owners: ownerPatches,
9187
+ statics: staticPatches,
9188
+ lifecycleIds,
9189
+ replacedLifecycleIds,
9190
+ };
9191
+ }
9192
+ function commitBoundIncremental(prepared, schema, draft, baseRevision, commitAuthoritativeVariables) {
9193
+ assertBoundRevision(baseRevision);
9194
+ commitAuthoritativeVariables?.();
9195
+ replaceRootContents(variables, draft);
9196
+ const stableMaterialized = materializeStreamingCard(schema, variables);
9197
+ const scrollPositions = captureBoundScrollPositions(container);
9198
+ const mediaStates = captureBoundMediaStates(container);
9199
+ for (const patch of prepared.owners) {
9200
+ disposeChartsIn(patch.oldElement);
9201
+ unindexBoundSubtree(patch.oldNode);
9202
+ }
9203
+ for (const patch of prepared.statics) {
9204
+ disposeChartsIn(patch.oldElement);
9205
+ unindexBoundSubtree(patch.oldNode);
9206
+ }
9207
+ for (const patch of prepared.owners) {
9208
+ patch.oldElement.replaceWith(patch.nextElement);
9209
+ mergeProductionIndexes(patch.indexes);
9210
+ }
9211
+ for (const patch of prepared.statics) {
9212
+ patch.oldElement.replaceWith(patch.nextElement);
9213
+ mergeProductionIndexes(patch.indexes);
9214
+ }
9215
+ currentSchema = schema;
9216
+ currentMaterialized = stableMaterialized;
9217
+ boundRevision += 1;
9218
+ actionContext = buildActionContext();
9219
+ reconcileBoundLifecycles(selectLifecycleNodes(stableMaterialized, prepared.lifecycleIds), prepared.replacedLifecycleIds);
9220
+ restoreBoundScrollPositions(container, scrollPositions);
9221
+ restoreBoundMediaStates(container, mediaStates);
9222
+ }
9223
+ function prepareAndCommitBoundUpdate(schema, draft, owners, baseRevision, commitAuthoritativeVariables, changedSourceIds = new Set()) {
9224
+ try {
9225
+ const prepared = prepareBoundIncremental(schema, draft, owners, changedSourceIds);
9226
+ commitBoundIncremental(prepared, schema, draft, baseRevision, commitAuthoritativeVariables);
9227
+ }
9228
+ catch (error) {
9229
+ if (!(error instanceof BoundPatchPreconditionError))
9230
+ throw error;
9231
+ const full = prepareBoundFull(schema, draft);
9232
+ commitBoundFull(full, schema, draft, baseRevision, commitAuthoritativeVariables);
9233
+ }
9234
+ }
6274
9235
  // ─── Action Steps (A2UI `action` support) ───────────────────────
6275
9236
  /**
6276
9237
  * Run an action-step chain. Steps of type `action` are emitted through
@@ -6305,6 +9266,174 @@ function renderStreamingCard(container, options = {}) {
6305
9266
  }
6306
9267
  }
6307
9268
  }
9269
+ function writeDraftVariable(draft, key, value) {
9270
+ Object.defineProperty(draft, String(key), {
9271
+ value,
9272
+ enumerable: true,
9273
+ configurable: true,
9274
+ writable: true,
9275
+ });
9276
+ }
9277
+ function createBoundActionContext(node, draft) {
9278
+ const write = (key, value) => {
9279
+ writeDraftVariable(draft, key, value);
9280
+ };
9281
+ return {
9282
+ ...createWebActionContext({
9283
+ ...options,
9284
+ setVariable: write,
9285
+ abortSignal: abortController.signal,
9286
+ }),
9287
+ variables: draft,
9288
+ expressionContext: createExpressionContext(node.scope),
9289
+ parameterResolver: node.bindingDialect === 'a2ui'
9290
+ ? createA2UIParameterResolver(draft, node.dataPath)
9291
+ : undefined,
9292
+ variableWriter: (key, value) => write(key, value),
9293
+ botId: options.botId,
9294
+ inflightRequests,
9295
+ };
9296
+ }
9297
+ function resolveActionParams(params, context) {
9298
+ if (context.parameterResolver) {
9299
+ return context.parameterResolver(params);
9300
+ }
9301
+ const resolutionContext = (context.expressionContext ?? context.variables);
9302
+ return resolutionContext
9303
+ ? resolveDeep(params, resolutionContext)
9304
+ : params;
9305
+ }
9306
+ async function runBoundSteps(steps, sourceNode, context) {
9307
+ for (const step of steps) {
9308
+ if (step.type !== 'action') {
9309
+ await runActionStep(step, context);
9310
+ continue;
9311
+ }
9312
+ const params = resolveActionParams(step.params ?? {}, context);
9313
+ const payload = {
9314
+ name: typeof params.name === 'string' ? params.name : 'action',
9315
+ surfaceId: currentSurfaceId,
9316
+ sourceComponentId: sourceNode.sourceId,
9317
+ timestamp: new Date().toISOString(),
9318
+ context: (params.context && typeof params.context === 'object')
9319
+ ? params.context
9320
+ : {},
9321
+ };
9322
+ try {
9323
+ options.onAction?.(payload);
9324
+ if (step.onSuccess) {
9325
+ await runBoundSteps(step.onSuccess, sourceNode, context);
9326
+ }
9327
+ }
9328
+ catch (error) {
9329
+ if (step.onFail) {
9330
+ await runBoundSteps(step.onFail, sourceNode, context);
9331
+ }
9332
+ else {
9333
+ throw error;
9334
+ }
9335
+ }
9336
+ }
9337
+ }
9338
+ function topLevelChangedPaths(before, after) {
9339
+ const keys = new Set([
9340
+ ...Object.keys(before),
9341
+ ...Object.keys(after),
9342
+ ]);
9343
+ const paths = [];
9344
+ for (const key of keys) {
9345
+ let equal = false;
9346
+ try {
9347
+ equal = JSON.stringify(before[key]) === JSON.stringify(after[key]);
9348
+ }
9349
+ catch {
9350
+ equal = false;
9351
+ }
9352
+ if (!equal) {
9353
+ paths.push(`/${key.replace(/~/g, '~0').replace(/\//g, '~1')}`);
9354
+ }
9355
+ }
9356
+ return paths;
9357
+ }
9358
+ function affectedOwnersForPaths(materialized, paths) {
9359
+ const owners = new Map();
9360
+ for (const path of paths) {
9361
+ for (const owner of findAffectedRepeatOwners(materialized, path)) {
9362
+ owners.set(owner.key, owner);
9363
+ }
9364
+ }
9365
+ return ownerKeysWithoutNestedDuplicates(materialized, [...owners.values()]);
9366
+ }
9367
+ function commitBoundDraftTransaction(before, draft, baseRevision) {
9368
+ assertBoundRevision(baseRevision);
9369
+ if (disposed
9370
+ || !currentMaterialized
9371
+ || !currentSchema
9372
+ || !currentSurfaceId) {
9373
+ const error = new Error('[renderStreamingCard] BOUND_TRANSACTION_CONFLICT');
9374
+ error.code = 'BOUND_TRANSACTION_CONFLICT';
9375
+ throw error;
9376
+ }
9377
+ const changedPaths = topLevelChangedPaths(before, draft);
9378
+ if (changedPaths.length === 0)
9379
+ return;
9380
+ const owners = currentMaterialized.unresolvedRepeatOwners.size > 0
9381
+ ? []
9382
+ : affectedOwnersForPaths(currentMaterialized, changedPaths);
9383
+ const schema = currentSchema;
9384
+ const surfaceId = currentSurfaceId;
9385
+ const commitAuthoritative = () => {
9386
+ const authoritative = engine.getSchema(surfaceId);
9387
+ if (!authoritative || authoritative !== schema) {
9388
+ const error = new Error('[renderStreamingCard] BOUND_TRANSACTION_CONFLICT');
9389
+ error.code = 'BOUND_TRANSACTION_CONFLICT';
9390
+ throw error;
9391
+ }
9392
+ replaceRootContents(authoritative.variables, draft);
9393
+ };
9394
+ if (currentMaterialized.unresolvedRepeatOwners.size > 0) {
9395
+ const full = prepareBoundFull(schema, draft);
9396
+ commitBoundFull(full, schema, draft, baseRevision, commitAuthoritative);
9397
+ return;
9398
+ }
9399
+ prepareAndCommitBoundUpdate(schema, draft, owners, baseRevision, commitAuthoritative);
9400
+ }
9401
+ async function runBoundEvent(runtimeId, eventName, eventDetail) {
9402
+ if (!currentMaterialized || !currentSchema || !currentSurfaceId)
9403
+ return;
9404
+ const baseRevision = boundRevision;
9405
+ const before = cloneJsonData(variables);
9406
+ const draft = cloneJsonData(variables);
9407
+ if (eventDetail !== undefined) {
9408
+ writeDraftVariable(draft, '_event', cloneJsonData(eventDetail));
9409
+ }
9410
+ const fresh = materializeStreamingCard(currentSchema, draft);
9411
+ const sourceNode = indexBoundNodes(fresh.root).get(runtimeId);
9412
+ if (!sourceNode) {
9413
+ throw new Error(`[renderStreamingCard] Bound runtime node "${runtimeId}" no longer exists`);
9414
+ }
9415
+ const eventValue = sourceNode.events?.[eventName];
9416
+ const steps = eventValue
9417
+ ? resolveActionRef(eventValue, currentSchema.actions ?? {})
9418
+ : undefined;
9419
+ if (!steps)
9420
+ return;
9421
+ await runBoundSteps(steps, sourceNode, createBoundActionContext(sourceNode, draft));
9422
+ commitBoundDraftTransaction(before, draft, baseRevision);
9423
+ }
9424
+ function enqueueBoundEvent(runtimeId, eventName, eventDetail) {
9425
+ boundActionQueue = boundActionQueue
9426
+ .then(() => runBoundEvent(runtimeId, eventName, eventDetail))
9427
+ .catch((error) => {
9428
+ if (error
9429
+ && typeof error === 'object'
9430
+ && error.code
9431
+ === 'BOUND_TRANSACTION_CONFLICT') {
9432
+ return;
9433
+ }
9434
+ console.error('[renderStreamingCard] Bound action failed', error);
9435
+ });
9436
+ }
6308
9437
  // ─── Partial-Schema Progressive Rendering ───────────────────────
6309
9438
  /** Lock the instance to one feeding style; mixing the two corrupts state. */
6310
9439
  function lockMode(mode) {
@@ -6327,6 +9456,8 @@ function renderStreamingCard(container, options = {}) {
6327
9456
  if (slot.groups)
6328
9457
  for (const g of slot.groups)
6329
9458
  out.push(...g);
9459
+ if (slot.repeat?.template)
9460
+ out.push(slot.repeat.template);
6330
9461
  const overlays = slot.config?.overlays;
6331
9462
  if (Array.isArray(overlays)) {
6332
9463
  for (const o of overlays)
@@ -6372,6 +9503,9 @@ function renderStreamingCard(container, options = {}) {
6372
9503
  return root;
6373
9504
  const prunedSlots = {};
6374
9505
  for (const [k, slot] of Object.entries(slots)) {
9506
+ if (slot.repeat?.template && !mountable.has(slot.repeat.template)) {
9507
+ continue;
9508
+ }
6375
9509
  const s = { ...slot };
6376
9510
  if (s.children)
6377
9511
  s.children = s.children.filter((id) => mountable.has(id));
@@ -6395,7 +9529,16 @@ function renderStreamingCard(container, options = {}) {
6395
9529
  if (!root)
6396
9530
  return; // root 未到齐 —— 首块出现前的占位由宿主负责
6397
9531
  if (!partialSurfaceCreated) {
6398
- engine.apply({ type: 'createSurface', surfaceId: PARTIAL_SURFACE_ID });
9532
+ engine.apply({
9533
+ type: 'createSurface',
9534
+ surfaceId: PARTIAL_SURFACE_ID,
9535
+ schema: {
9536
+ version: result.version ?? '1.0',
9537
+ rootID,
9538
+ variables: {},
9539
+ elements: {},
9540
+ },
9541
+ });
6399
9542
  partialSurfaceCreated = true;
6400
9543
  }
6401
9544
  if (!partialVariablesSent && result.variables && Object.keys(result.variables).length > 0) {
@@ -6461,6 +9604,16 @@ function renderStreamingCard(container, options = {}) {
6461
9604
  if (!schema)
6462
9605
  return;
6463
9606
  currentSchema = schema; // keep local ref pointing at the live engine schema
9607
+ if (requiresBindingMaterialization(schema)) {
9608
+ const baseRevision = boundRevision;
9609
+ const draft = cloneJsonData(variables);
9610
+ const prepared = prepareBoundFull(schema, draft);
9611
+ commitBoundFull(prepared, schema, draft, baseRevision, () => replaceRootContents(schema.variables, draft));
9612
+ return;
9613
+ }
9614
+ currentMaterialized = null;
9615
+ sourceOccurrences.clear();
9616
+ teardownBoundLifecycles();
6464
9617
  disposeChartsIn(container); // release old ECharts instances before clearing
6465
9618
  container.innerHTML = '';
6466
9619
  elementMap.clear();
@@ -6482,6 +9635,8 @@ function renderStreamingCard(container, options = {}) {
6482
9635
  renderFull();
6483
9636
  }
6484
9637
  catch (error) {
9638
+ if (error instanceof InvalidBoundSchemaError)
9639
+ throw error;
6485
9640
  console.warn('[renderStreamingCard] Deferred render (incomplete schema):', error);
6486
9641
  }
6487
9642
  }
@@ -6496,6 +9651,8 @@ function renderStreamingCard(container, options = {}) {
6496
9651
  // ─── Engine Event Handlers ──────────────────────────────────────
6497
9652
  const engine = new StreamingEngine({
6498
9653
  onSurfaceCreated(surfaceId, schemaInput) {
9654
+ teardownBoundLifecycles();
9655
+ boundRevision += 1;
6499
9656
  currentSurfaceId = surfaceId;
6500
9657
  if (schemaInput) {
6501
9658
  currentSchema = normalizeSchema(schemaInput);
@@ -6505,6 +9662,8 @@ function renderStreamingCard(container, options = {}) {
6505
9662
  else {
6506
9663
  // Empty surface (A2UI-style): structure arrives via updateComponents
6507
9664
  currentSchema = null;
9665
+ currentMaterialized = null;
9666
+ sourceOccurrences.clear();
6508
9667
  variables = { ...options.variables };
6509
9668
  disposeChartsIn(container); // release old ECharts instances before clearing
6510
9669
  container.innerHTML = '';
@@ -6517,6 +9676,71 @@ function renderStreamingCard(container, options = {}) {
6517
9676
  const schema = engine.getSchema(surfaceId);
6518
9677
  if (!schema)
6519
9678
  return;
9679
+ const previousSchema = currentSchema;
9680
+ const previousBound = currentMaterialized !== null
9681
+ || (previousSchema !== null
9682
+ && requiresBindingMaterialization(previousSchema));
9683
+ const nextBound = requiresBindingMaterialization(schema);
9684
+ if (previousBound || nextBound) {
9685
+ const baseRevision = boundRevision;
9686
+ if (!nextBound) {
9687
+ teardownBoundLifecycles();
9688
+ variables = {
9689
+ ...schema.variables,
9690
+ ...variables,
9691
+ };
9692
+ currentSchema = schema;
9693
+ currentMaterialized = null;
9694
+ boundRevision += 1;
9695
+ renderFull();
9696
+ return;
9697
+ }
9698
+ const draft = cloneJsonData(currentMaterialized
9699
+ ? schema.variables
9700
+ : {
9701
+ ...schema.variables,
9702
+ ...variables,
9703
+ });
9704
+ const previousFingerprint = previousSchema
9705
+ ? bindingTopologyFingerprint(previousSchema)
9706
+ : '';
9707
+ const nextFingerprint = bindingTopologyFingerprint(schema);
9708
+ const topologyChanged = (!previousBound
9709
+ || previousFingerprint !== nextFingerprint);
9710
+ const rootMissing = (!elementMap.get(schema.rootID)?.isConnected);
9711
+ const commitAuthoritative = () => {
9712
+ replaceRootContents(schema.variables, draft);
9713
+ };
9714
+ try {
9715
+ if (!currentMaterialized
9716
+ || topologyChanged
9717
+ || rootMissing) {
9718
+ const full = prepareBoundFull(schema, draft);
9719
+ commitBoundFull(full, schema, draft, baseRevision, commitAuthoritative);
9720
+ return;
9721
+ }
9722
+ const changedSourceIds = changes.map(change => change.elementId);
9723
+ const hasTemplateDependency = changedSourceIds.some(sourceId => currentMaterialized.dependencies.has(`component:${sourceId}`));
9724
+ const owners = findTemplateRepeatOwners(currentMaterialized, changedSourceIds);
9725
+ if (hasTemplateDependency && owners.length === 0) {
9726
+ const full = prepareBoundFull(schema, draft);
9727
+ commitBoundFull(full, schema, draft, baseRevision, commitAuthoritative);
9728
+ return;
9729
+ }
9730
+ prepareAndCommitBoundUpdate(schema, draft, owners, baseRevision, commitAuthoritative, new Set(changedSourceIds));
9731
+ }
9732
+ catch (error) {
9733
+ if (error instanceof IncompleteBoundSchemaError) {
9734
+ // Progressive component streams keep their authoritative candidate
9735
+ // until the missing template/descendant arrives.
9736
+ currentSchema = schema;
9737
+ boundRevision += 1;
9738
+ return;
9739
+ }
9740
+ throw error;
9741
+ }
9742
+ return;
9743
+ }
6520
9744
  currentSchema = schema;
6521
9745
  schema.actions ?? {};
6522
9746
  let needFull = false;
@@ -6631,9 +9855,64 @@ function renderStreamingCard(container, options = {}) {
6631
9855
  if (needFull)
6632
9856
  safeRenderFull();
6633
9857
  },
6634
- onDataModelUpdated(surfaceId, path, value) {
9858
+ onDataModelUpdated(surfaceId, path, value, pathDialect) {
6635
9859
  if (!currentSchema && !engine.getSchema(surfaceId))
6636
9860
  return;
9861
+ const candidateSchema = engine.getSchema(surfaceId);
9862
+ if (candidateSchema
9863
+ && (currentMaterialized !== null
9864
+ || requiresBindingMaterialization(candidateSchema))) {
9865
+ const baseRevision = boundRevision;
9866
+ const draft = cloneJsonData(currentMaterialized
9867
+ ? candidateSchema.variables
9868
+ : {
9869
+ ...candidateSchema.variables,
9870
+ ...variables,
9871
+ });
9872
+ const commitAuthoritative = () => {
9873
+ replaceRootContents(candidateSchema.variables, draft);
9874
+ };
9875
+ try {
9876
+ if (!currentMaterialized) {
9877
+ const full = prepareBoundFull(candidateSchema, draft);
9878
+ commitBoundFull(full, candidateSchema, draft, baseRevision, commitAuthoritative);
9879
+ return;
9880
+ }
9881
+ if (currentMaterialized.unresolvedRepeatOwners.size > 0) {
9882
+ const full = prepareBoundFull(candidateSchema, draft);
9883
+ commitBoundFull(full, candidateSchema, draft, baseRevision, commitAuthoritative);
9884
+ return;
9885
+ }
9886
+ const normalizedPath = pathDialect === 'a2ui'
9887
+ ? path
9888
+ : path === '/' || path === ''
9889
+ ? ''
9890
+ : `/${path.replace(/^\//, '')
9891
+ .split('/')
9892
+ .filter(Boolean)
9893
+ .join('/')}`;
9894
+ const owners = findAffectedRepeatOwners(currentMaterialized, normalizedPath);
9895
+ prepareAndCommitBoundUpdate(candidateSchema, draft, owners, baseRevision, commitAuthoritative);
9896
+ }
9897
+ catch (error) {
9898
+ if (error instanceof IncompleteBoundSchemaError) {
9899
+ currentSchema = candidateSchema;
9900
+ boundRevision += 1;
9901
+ return;
9902
+ }
9903
+ throw error;
9904
+ }
9905
+ return;
9906
+ }
9907
+ if (pathDialect === 'a2ui') {
9908
+ const authoritative = engine.getSchema(surfaceId);
9909
+ if (authoritative) {
9910
+ variables = cloneJsonData(authoritative.variables);
9911
+ actionContext = buildActionContext();
9912
+ }
9913
+ diffAllElements();
9914
+ return;
9915
+ }
6637
9916
  // Update the local variables copy (JSON Pointer-style path).
6638
9917
  // Mirror the engine's setByPath prototype-pollution guard: reject any
6639
9918
  // __proto__ / constructor / prototype segment (stream is untrusted).
@@ -6666,6 +9945,18 @@ function renderStreamingCard(container, options = {}) {
6666
9945
  },
6667
9946
  onContentAppended(surfaceId, elementId, content) {
6668
9947
  const el = elementMap.get(elementId);
9948
+ if (currentMaterialized && currentSchema && el) {
9949
+ const nextMaterialized = materializeStreamingCard(currentSchema, variables);
9950
+ const node = indexBoundNodes(nextMaterialized.root).get(elementId);
9951
+ if (node && node.id === node.sourceId && 'updateProps' in el) {
9952
+ const resolved = resolveBoundNodeProps(node, variables);
9953
+ el.updateProps(resolved, isMobile);
9954
+ propsCache.set(elementId, boundNodeFingerprint(node, variables, resolved));
9955
+ currentMaterialized = nextMaterialized;
9956
+ boundRevision += 1;
9957
+ return;
9958
+ }
9959
+ }
6669
9960
  if (el && 'updateProps' in el) {
6670
9961
  // Append content to Text component via updateProps
6671
9962
  const currentProps = el._props ?? {};
@@ -6694,6 +9985,10 @@ function renderStreamingCard(container, options = {}) {
6694
9985
  hiddenSet.clear();
6695
9986
  currentSchema = null;
6696
9987
  currentSurfaceId = null;
9988
+ currentMaterialized = null;
9989
+ sourceOccurrences.clear();
9990
+ teardownBoundLifecycles();
9991
+ boundRevision += 1;
6697
9992
  lifecycleManager.dispose(actionContext);
6698
9993
  },
6699
9994
  });
@@ -6735,18 +10030,55 @@ function renderStreamingCard(container, options = {}) {
6735
10030
  return surfaceId ? engine.getSchema(surfaceId) : currentSchema ?? undefined;
6736
10031
  },
6737
10032
  updateVariables(newVars) {
10033
+ if (currentMaterialized && currentSchema && currentSurfaceId) {
10034
+ const baseRevision = boundRevision;
10035
+ const before = cloneJsonData(variables);
10036
+ const draft = cloneJsonData(variables);
10037
+ const patch = cloneJsonData(newVars);
10038
+ for (const key of Object.keys(patch)) {
10039
+ writeDraftVariable(draft, key, patch[key]);
10040
+ }
10041
+ const changedPaths = topLevelChangedPaths(before, draft);
10042
+ const owners = currentMaterialized.unresolvedRepeatOwners.size > 0
10043
+ ? []
10044
+ : affectedOwnersForPaths(currentMaterialized, changedPaths);
10045
+ const schema = currentSchema;
10046
+ const commitAuthoritative = () => {
10047
+ const authoritative = engine.getSchema(currentSurfaceId);
10048
+ if (!authoritative || authoritative !== schema) {
10049
+ const error = new Error('[renderStreamingCard] BOUND_TRANSACTION_CONFLICT');
10050
+ error.code = 'BOUND_TRANSACTION_CONFLICT';
10051
+ throw error;
10052
+ }
10053
+ replaceRootContents(authoritative.variables, draft);
10054
+ };
10055
+ if (currentMaterialized.unresolvedRepeatOwners.size > 0) {
10056
+ const full = prepareBoundFull(schema, draft);
10057
+ commitBoundFull(full, schema, draft, baseRevision, commitAuthoritative);
10058
+ }
10059
+ else {
10060
+ prepareAndCommitBoundUpdate(schema, draft, owners, baseRevision, commitAuthoritative);
10061
+ }
10062
+ return;
10063
+ }
6738
10064
  variables = { ...variables, ...newVars };
6739
10065
  if (currentSchema || (currentSurfaceId && engine.getSchema(currentSurfaceId))) {
6740
10066
  diffAllElements();
6741
10067
  }
6742
10068
  },
6743
10069
  dispose() {
10070
+ if (disposed)
10071
+ return;
10072
+ disposed = true;
10073
+ boundRevision += 1;
6744
10074
  abortController.abort();
6745
10075
  removeViewportListener();
10076
+ teardownBoundLifecycles();
6746
10077
  lifecycleManager.dispose(actionContext);
6747
10078
  disposeChartsIn(container); // release ECharts instances before clearing
6748
10079
  container.innerHTML = '';
6749
10080
  elementMap.clear();
10081
+ sourceOccurrences.clear();
6750
10082
  engine.dispose();
6751
10083
  parser.reset();
6752
10084
  },
@@ -7090,4 +10422,4 @@ class RemoteActionConfigProvider {
7090
10422
  }
7091
10423
  }
7092
10424
 
7093
- export { BaseElement, BotSDK, CardButton, CardCollapse, CardDivider, CardForm, CardHtml, CardIcon, CardImage, CardInput, CardLoading, CardPasscodeInput, CardProgress, CardRate, CardSelect, CardSteps, CardTag, CardText, LocalActionConfigProvider, RemoteActionConfigProvider, buildStyleString, componentRenderers, connectSSE, connectStreaming, createWebActionContext, isMobileViewport, onViewportChange, pxToRem, pxToVw, registerComponent, renderCard, renderStreamingCard, resolveSize, sanitizeHtml, trimIncompleteTag };
10425
+ export { BaseElement, BotSDK, CardButton, CardChoiceItem, CardChoiceList, CardCollapse, CardCounter, CardDivider, CardForm, CardHtml, CardIcon, CardImage, CardInput, CardLoading, CardPasscodeInput, CardProgress, CardRate, CardSelect, CardSteps, CardSwitch, CardTag, CardText, LocalActionConfigProvider, RemoteActionConfigProvider, buildStyleString, componentRenderers, connectSSE, connectStreaming, createWebActionContext, isMobileViewport, onViewportChange, pxToRem, pxToVw, registerComponent, renderCard, renderStreamingCard, resolveSize, sanitizeHtml, trimIncompleteTag };