@peektravel/app-utilities 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -665,6 +665,13 @@ declare class OdyInput extends OdyElement {
665
665
  /** Current field value. */
666
666
  get value(): string;
667
667
  set value(next: string);
668
+ /**
669
+ * Reflect `value` into the live control in place — the native field already
670
+ * shows what the user typed, so rebuilding it (as a full re-render would)
671
+ * needlessly drops focus and caret. Every other observed attribute changes
672
+ * the chrome and still re-renders via the base implementation.
673
+ */
674
+ attributeChangedCallback(name?: string, oldValue?: string | null, newValue?: string | null): void;
668
675
  protected render(): void;
669
676
  }
670
677
 
@@ -686,6 +693,13 @@ declare class OdyInlineInput extends OdyElement {
686
693
  /** Current field value. */
687
694
  get value(): string;
688
695
  set value(next: string);
696
+ /**
697
+ * Reflect `value` into the live control in place — the native field already
698
+ * shows what the user typed, so rebuilding it (as a full re-render would)
699
+ * needlessly drops focus and caret. Every other observed attribute changes
700
+ * the chrome and still re-renders via the base implementation.
701
+ */
702
+ attributeChangedCallback(name?: string, oldValue?: string | null, newValue?: string | null): void;
689
703
  protected render(): void;
690
704
  }
691
705
 
@@ -711,6 +725,13 @@ declare class OdySearchInput extends OdyElement {
711
725
  /** Current field value. */
712
726
  get value(): string;
713
727
  set value(next: string);
728
+ /**
729
+ * Reflect `value` into the live control in place — the native field already
730
+ * shows what the user typed, so rebuilding it (as a full re-render would)
731
+ * needlessly drops focus, caret and the `--focused` state. Every other
732
+ * observed attribute changes the chrome and still re-renders via the base.
733
+ */
734
+ attributeChangedCallback(name?: string, oldValue?: string | null, newValue?: string | null): void;
714
735
  protected render(): void;
715
736
  }
716
737
 
@@ -918,9 +939,15 @@ declare class OdyTabs extends OdyElement {
918
939
  }
919
940
 
920
941
  /**
921
- * `<ody-copy-button>` — a button that copies its `value` to the clipboard via
922
- * `navigator.clipboard.writeText`, showing a transient success (or error) state.
923
- * Dispatches a `copy` CustomEvent with `{ value, ok }`.
942
+ * `<ody-copy-button>` — a button that copies its `value` to the clipboard,
943
+ * showing a transient success (or error) state. Dispatches a `copy`
944
+ * CustomEvent with `{ value, ok }`.
945
+ *
946
+ * The copy uses a synchronous `document.execCommand('copy')` as the source of
947
+ * truth so it works inside cross-origin iframes (where the async Clipboard API
948
+ * is blocked by default) and legacy contexts. The async
949
+ * `navigator.clipboard.writeText` is used only as a best-effort enhancement
950
+ * when the synchronous path can't run.
924
951
  *
925
952
  * Attributes:
926
953
  * - `value` — the text copied to the clipboard.
@@ -665,6 +665,13 @@ declare class OdyInput extends OdyElement {
665
665
  /** Current field value. */
666
666
  get value(): string;
667
667
  set value(next: string);
668
+ /**
669
+ * Reflect `value` into the live control in place — the native field already
670
+ * shows what the user typed, so rebuilding it (as a full re-render would)
671
+ * needlessly drops focus and caret. Every other observed attribute changes
672
+ * the chrome and still re-renders via the base implementation.
673
+ */
674
+ attributeChangedCallback(name?: string, oldValue?: string | null, newValue?: string | null): void;
668
675
  protected render(): void;
669
676
  }
670
677
 
@@ -686,6 +693,13 @@ declare class OdyInlineInput extends OdyElement {
686
693
  /** Current field value. */
687
694
  get value(): string;
688
695
  set value(next: string);
696
+ /**
697
+ * Reflect `value` into the live control in place — the native field already
698
+ * shows what the user typed, so rebuilding it (as a full re-render would)
699
+ * needlessly drops focus and caret. Every other observed attribute changes
700
+ * the chrome and still re-renders via the base implementation.
701
+ */
702
+ attributeChangedCallback(name?: string, oldValue?: string | null, newValue?: string | null): void;
689
703
  protected render(): void;
690
704
  }
691
705
 
@@ -711,6 +725,13 @@ declare class OdySearchInput extends OdyElement {
711
725
  /** Current field value. */
712
726
  get value(): string;
713
727
  set value(next: string);
728
+ /**
729
+ * Reflect `value` into the live control in place — the native field already
730
+ * shows what the user typed, so rebuilding it (as a full re-render would)
731
+ * needlessly drops focus, caret and the `--focused` state. Every other
732
+ * observed attribute changes the chrome and still re-renders via the base.
733
+ */
734
+ attributeChangedCallback(name?: string, oldValue?: string | null, newValue?: string | null): void;
714
735
  protected render(): void;
715
736
  }
716
737
 
@@ -918,9 +939,15 @@ declare class OdyTabs extends OdyElement {
918
939
  }
919
940
 
920
941
  /**
921
- * `<ody-copy-button>` — a button that copies its `value` to the clipboard via
922
- * `navigator.clipboard.writeText`, showing a transient success (or error) state.
923
- * Dispatches a `copy` CustomEvent with `{ value, ok }`.
942
+ * `<ody-copy-button>` — a button that copies its `value` to the clipboard,
943
+ * showing a transient success (or error) state. Dispatches a `copy`
944
+ * CustomEvent with `{ value, ok }`.
945
+ *
946
+ * The copy uses a synchronous `document.execCommand('copy')` as the source of
947
+ * truth so it works inside cross-origin iframes (where the async Clipboard API
948
+ * is blocked by default) and legacy contexts. The async
949
+ * `navigator.clipboard.writeText` is used only as a best-effort enhancement
950
+ * when the synchronous path can't run.
924
951
  *
925
952
  * Attributes:
926
953
  * - `value` — the text copied to the clipboard.
package/dist/ui/index.js CHANGED
@@ -157,6 +157,9 @@ function escapeHtml(value) {
157
157
  function classes(...parts) {
158
158
  return parts.filter(Boolean).join(" ");
159
159
  }
160
+ function reflectControlValue(control, value) {
161
+ if (control && control.value !== value) control.value = value;
162
+ }
160
163
  function define(tag, ctor) {
161
164
  if (typeof customElements === "undefined") return;
162
165
  if (!customElements.get(tag)) customElements.define(tag, ctor);
@@ -1350,6 +1353,28 @@ var OdyInput = class extends OdyElement {
1350
1353
  this.#value = next;
1351
1354
  this.setAttribute("value", next);
1352
1355
  }
1356
+ /**
1357
+ * Reflect `value` into the live control in place — the native field already
1358
+ * shows what the user typed, so rebuilding it (as a full re-render would)
1359
+ * needlessly drops focus and caret. Every other observed attribute changes
1360
+ * the chrome and still re-renders via the base implementation.
1361
+ */
1362
+ attributeChangedCallback(name, oldValue, newValue) {
1363
+ if (name === "value") {
1364
+ if (oldValue === newValue) return;
1365
+ const value = newValue ?? "";
1366
+ reflectControlValue(
1367
+ this.querySelector(
1368
+ ".ody-input__field, .ody-input__textarea"
1369
+ ),
1370
+ value
1371
+ );
1372
+ this.#syncCounter(value);
1373
+ this.#syncClearButton(value);
1374
+ return;
1375
+ }
1376
+ super.attributeChangedCallback();
1377
+ }
1353
1378
  render() {
1354
1379
  const size = this.attr("size", "base");
1355
1380
  const isTextarea = this.flag("textarea");
@@ -1379,8 +1404,7 @@ var OdyInput = class extends OdyElement {
1379
1404
  `ody-input__container--${size}`
1380
1405
  );
1381
1406
  const field = isTextarea ? `<textarea class="ody-input__textarea" rows="3"${maxlengthAttr}${isReadonly ? " readonly" : ""}${isDisabled ? " disabled" : ""}>${this.esc(value)}</textarea>` : `<input class="ody-input__field" type="text" value="${this.esc(value)}" placeholder="${this.esc(this.attr("placeholder"))}"${maxlengthAttr}${isReadonly ? " readonly" : ""}${isDisabled ? " disabled" : ""} />`;
1382
- const clearEnabled = !this.flag("no-clear") && !isDisabled && !isReadonly && value !== "";
1383
- const clearEl = clearEnabled ? `<button type="button" class="btn ody-input__clear-button" aria-label="${this.localized("clear-label", "clear")}">${iconSvg("close", "icon__svg clear-icon")}</button>` : "";
1407
+ const clearEl = this.#clearEnabled(value) ? this.#clearButtonHtml() : "";
1384
1408
  const showCounter = !isReadonly && !isDisabled && maxlength !== "";
1385
1409
  const messages = classes(
1386
1410
  caption && "has-caption",
@@ -1402,7 +1426,6 @@ var OdyInput = class extends OdyElement {
1402
1426
  #onInput = (event) => {
1403
1427
  event.stopPropagation();
1404
1428
  const value = event.target.value;
1405
- this.#syncCounter(value);
1406
1429
  this.value = value;
1407
1430
  this.dispatchEvent(new CustomEvent("input", { detail: { value }, bubbles: true }));
1408
1431
  };
@@ -1422,6 +1445,26 @@ var OdyInput = class extends OdyElement {
1422
1445
  const max = this.attr("maxlength");
1423
1446
  if (counter && max) counter.textContent = `${value.length} / ${max}`;
1424
1447
  }
1448
+ /** Whether the clear button should be shown for the given value. */
1449
+ #clearEnabled(value) {
1450
+ return !this.flag("no-clear") && !this.flag("disabled") && !this.flag("readonly") && value !== "";
1451
+ }
1452
+ /** Markup for the clear button (shared by render and the in-place sync). */
1453
+ #clearButtonHtml() {
1454
+ return `<button type="button" class="btn ody-input__clear-button" aria-label="${this.localized("clear-label", "clear")}">${iconSvg("close", "icon__svg clear-icon")}</button>`;
1455
+ }
1456
+ /** Add or remove the clear button in place as the value gains/loses content. */
1457
+ #syncClearButton(value) {
1458
+ const existing = this.querySelector(".ody-input__clear-button");
1459
+ if (this.#clearEnabled(value)) {
1460
+ if (existing) return;
1461
+ const control = this.querySelector(".ody-input__field, .ody-input__textarea");
1462
+ control?.insertAdjacentHTML("afterend", this.#clearButtonHtml());
1463
+ this.querySelector(".ody-input__clear-button")?.addEventListener("click", this.#onClear);
1464
+ } else {
1465
+ existing?.remove();
1466
+ }
1467
+ }
1425
1468
  };
1426
1469
  define("ody-input", OdyInput);
1427
1470
 
@@ -1453,6 +1496,28 @@ var OdyInlineInput = class extends OdyElement {
1453
1496
  this.#value = next;
1454
1497
  this.setAttribute("value", next);
1455
1498
  }
1499
+ /**
1500
+ * Reflect `value` into the live control in place — the native field already
1501
+ * shows what the user typed, so rebuilding it (as a full re-render would)
1502
+ * needlessly drops focus and caret. Every other observed attribute changes
1503
+ * the chrome and still re-renders via the base implementation.
1504
+ */
1505
+ attributeChangedCallback(name, oldValue, newValue) {
1506
+ if (name === "value") {
1507
+ if (oldValue === newValue) return;
1508
+ const value = newValue ?? "";
1509
+ reflectControlValue(
1510
+ this.querySelector(
1511
+ ".ody-inline-input__field, .ody-inline-input__textarea"
1512
+ ),
1513
+ value
1514
+ );
1515
+ this.#syncCounter(value);
1516
+ this.#syncClearButton(value);
1517
+ return;
1518
+ }
1519
+ super.attributeChangedCallback();
1520
+ }
1456
1521
  render() {
1457
1522
  const size = this.attr("size", "base");
1458
1523
  const isTextarea = this.flag("textarea");
@@ -1482,8 +1547,7 @@ var OdyInlineInput = class extends OdyElement {
1482
1547
  `ody-inline-input__group--${size}`
1483
1548
  );
1484
1549
  const field = isTextarea ? `<textarea class="ody-inline-input__textarea" rows="3"${maxlengthAttr}${isReadonly ? " readonly" : ""}${isDisabled ? " disabled" : ""}>${this.esc(value)}</textarea>` : `<input class="ody-inline-input__field" type="text" value="${this.esc(value)}" placeholder="${this.esc(this.attr("placeholder"))}"${maxlengthAttr}${isReadonly ? " readonly" : ""}${isDisabled ? " disabled" : ""} />`;
1485
- const clearEnabled = !this.flag("no-clear") && !isDisabled && !isReadonly && value !== "";
1486
- const clearEl = clearEnabled ? `<button type="button" class="btn ody-inline-input__clear-button" aria-label="${this.localized("clear-label", "clear")}">${iconSvg("close", "icon__svg clear-icon")}</button>` : "";
1550
+ const clearEl = this.#clearEnabled(value) ? this.#clearButtonHtml() : "";
1487
1551
  const showCounter = !isReadonly && !isDisabled && maxlength !== "";
1488
1552
  const footerNeeded = showCounter || caption !== "" || warning !== "" || info !== "";
1489
1553
  const footer = footerNeeded ? `<div class="ody-inline-input-footer"><div class="ody-inline-input-footer__message">` + (caption ? `<span class="ody-inline-input-footer__message__caption">${this.esc(caption)}</span>` : "") + (warning ? `<span class="ody-inline-input-footer__message__warning">${this.esc(warning)}</span>` : "") + (info ? `<span class="ody-inline-input-footer__message__info">${this.esc(info)}</span>` : "") + `</div>` + (showCounter ? `<div class="ody-inline-input-footer__length-message">${value.length} / ${this.esc(maxlength)}</div>` : "") + `</div>` : "";
@@ -1500,7 +1564,6 @@ var OdyInlineInput = class extends OdyElement {
1500
1564
  #onInput = (event) => {
1501
1565
  event.stopPropagation();
1502
1566
  const value = event.target.value;
1503
- this.#syncCounter(value);
1504
1567
  this.value = value;
1505
1568
  this.dispatchEvent(new CustomEvent("input", { detail: { value }, bubbles: true }));
1506
1569
  };
@@ -1519,6 +1582,26 @@ var OdyInlineInput = class extends OdyElement {
1519
1582
  const max = this.attr("maxlength");
1520
1583
  if (counter && max) counter.textContent = `${value.length} / ${max}`;
1521
1584
  }
1585
+ /** Whether the clear button should be shown for the given value. */
1586
+ #clearEnabled(value) {
1587
+ return !this.flag("no-clear") && !this.flag("disabled") && !this.flag("readonly") && value !== "";
1588
+ }
1589
+ /** Markup for the clear button (shared by render and the in-place sync). */
1590
+ #clearButtonHtml() {
1591
+ return `<button type="button" class="btn ody-inline-input__clear-button" aria-label="${this.localized("clear-label", "clear")}">${iconSvg("close", "icon__svg clear-icon")}</button>`;
1592
+ }
1593
+ /** Add or remove the clear button in place as the value gains/loses content. */
1594
+ #syncClearButton(value) {
1595
+ const existing = this.querySelector(".ody-inline-input__clear-button");
1596
+ if (this.#clearEnabled(value)) {
1597
+ if (existing) return;
1598
+ const control = this.querySelector(".ody-inline-input__field, .ody-inline-input__textarea");
1599
+ control?.insertAdjacentHTML("afterend", this.#clearButtonHtml());
1600
+ this.querySelector(".ody-inline-input__clear-button")?.addEventListener("click", this.#onClear);
1601
+ } else {
1602
+ existing?.remove();
1603
+ }
1604
+ }
1522
1605
  };
1523
1606
  define("ody-inline-input", OdyInlineInput);
1524
1607
 
@@ -1542,6 +1625,20 @@ var OdySearchInput = class extends OdyElement {
1542
1625
  this.#value = next;
1543
1626
  this.setAttribute("value", next);
1544
1627
  }
1628
+ /**
1629
+ * Reflect `value` into the live control in place — the native field already
1630
+ * shows what the user typed, so rebuilding it (as a full re-render would)
1631
+ * needlessly drops focus, caret and the `--focused` state. Every other
1632
+ * observed attribute changes the chrome and still re-renders via the base.
1633
+ */
1634
+ attributeChangedCallback(name, oldValue, newValue) {
1635
+ if (name === "value") {
1636
+ if (oldValue === newValue) return;
1637
+ reflectControlValue(this.querySelector(".ody-input__field"), newValue ?? "");
1638
+ return;
1639
+ }
1640
+ super.attributeChangedCallback();
1641
+ }
1545
1642
  render() {
1546
1643
  const size = this.attr("size", "base");
1547
1644
  const isDisabled = this.flag("disabled");
@@ -2052,16 +2149,47 @@ var OdyCopyButton = class extends OdyElement {
2052
2149
  }
2053
2150
  #onClick = () => {
2054
2151
  const value = this.attr("value");
2055
- const clipboard = typeof navigator !== "undefined" ? navigator.clipboard : void 0;
2056
- if (!clipboard || typeof clipboard.writeText !== "function") {
2057
- this.#feedback("error", value);
2058
- return;
2152
+ const ok = this.#execCopy(value);
2153
+ if (!ok) {
2154
+ const clipboard = typeof navigator !== "undefined" ? navigator.clipboard : void 0;
2155
+ if (clipboard && typeof clipboard.writeText === "function") {
2156
+ void clipboard.writeText(value).then(
2157
+ () => void 0,
2158
+ () => void 0
2159
+ );
2160
+ }
2059
2161
  }
2060
- void clipboard.writeText(value).then(
2061
- () => this.#feedback("success", value),
2062
- () => this.#feedback("error", value)
2063
- );
2162
+ this.#feedback(ok ? "success" : "error", value);
2064
2163
  };
2164
+ /**
2165
+ * Synchronous clipboard write. Must be called within the user-gesture window
2166
+ * (i.e. directly from the click handler, not from an async continuation).
2167
+ * Works in cross-origin iframes and legacy contexts where the async Clipboard
2168
+ * API is unavailable or blocked.
2169
+ */
2170
+ #execCopy(value) {
2171
+ if (!value || typeof document === "undefined") return false;
2172
+ const el = document.createElement("textarea");
2173
+ el.value = value;
2174
+ el.setAttribute("readonly", "");
2175
+ el.style.position = "fixed";
2176
+ el.style.top = "0";
2177
+ el.style.left = "0";
2178
+ el.style.opacity = "0";
2179
+ document.body.appendChild(el);
2180
+ el.select();
2181
+ try {
2182
+ el.setSelectionRange(0, value.length);
2183
+ } catch {
2184
+ }
2185
+ let ok = false;
2186
+ try {
2187
+ ok = document.execCommand("copy");
2188
+ } catch {
2189
+ }
2190
+ document.body.removeChild(el);
2191
+ return ok;
2192
+ }
2065
2193
  #feedback(state, value) {
2066
2194
  this.#state = state;
2067
2195
  this.render();