@peektravel/app-utilities 0.5.3 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/ui/index.cjs CHANGED
@@ -54,9 +54,31 @@ var OdyElement = class extends HTMLElement {
54
54
  /** Self-pruning re-render hook subscribed to locale changes (set on mount). */
55
55
  #localeCb = null;
56
56
  connectedCallback() {
57
- queueMicrotask(() => {
58
- if (this.isConnected && this.#slot === null) this.render();
59
- });
57
+ this.#upgradeProperties();
58
+ if (this.#slot === null) {
59
+ queueMicrotask(() => {
60
+ if (this.isConnected && this.#slot === null) this.render();
61
+ });
62
+ } else {
63
+ this.render();
64
+ }
65
+ }
66
+ /** Re-apply own properties that shadow a prototype accessor (pre-upgrade sets). */
67
+ #upgradeProperties() {
68
+ const self = this;
69
+ for (const key of Object.getOwnPropertyNames(this)) {
70
+ let proto = Object.getPrototypeOf(this);
71
+ while (proto && proto !== HTMLElement.prototype) {
72
+ const desc = Object.getOwnPropertyDescriptor(proto, key);
73
+ if (desc && (desc.get || desc.set)) {
74
+ const value = self[key];
75
+ delete self[key];
76
+ self[key] = value;
77
+ break;
78
+ }
79
+ proto = Object.getPrototypeOf(proto);
80
+ }
81
+ }
60
82
  }
61
83
  attributeChangedCallback() {
62
84
  if (this.#slot !== null) this.render();
@@ -85,6 +107,77 @@ var OdyElement = class extends HTMLElement {
85
107
  }
86
108
  return this.#slot;
87
109
  }
110
+ /**
111
+ * Move slotted content out of a portaled chrome node (dialog/panel/bubble)
112
+ * back into the captured slot fragment, so it survives `disconnectedCallback`
113
+ * tearing that node down and is re-slotted by the re-render on reconnect.
114
+ * Portal components call this before `removePortal(...)`; without it, moving
115
+ * the element in the DOM would permanently lose the consumer's content.
116
+ */
117
+ reclaimPortaledSlot(node) {
118
+ if (this.#slot === null || !node) return;
119
+ const slot = node.querySelector("[data-ody-slot]");
120
+ if (slot) while (slot.firstChild) this.#slot.appendChild(slot.firstChild);
121
+ }
122
+ /**
123
+ * The element that actually holds slotted (consumer-provided) children right
124
+ * now, or `null` before the first render, for a non-slotting component, or
125
+ * while the slot is portaled out of the host (e.g. an open modal). See the
126
+ * child-mutation overrides below for why this is needed.
127
+ */
128
+ #slotTarget() {
129
+ return this.#slot !== null ? this.querySelector("[data-ody-slot]") : null;
130
+ }
131
+ // --- Framework-reconciler child mutations -------------------------------
132
+ //
133
+ // `mount()` physically relocates the consumer's light-DOM children into the
134
+ // internal `[data-ody-slot]` node, so a slotted child's real `parentNode` is
135
+ // that slot div, not the host `<ody-*>` element. Framework reconcilers
136
+ // (React, Vue, Angular, Svelte, …) don't track parent pointers — to move or
137
+ // remove a node they call `host.removeChild(child)` /
138
+ // `host.insertBefore(node, ref)` where `host` is the element from their
139
+ // virtual tree. Because the child no longer lives directly under the host,
140
+ // those calls would throw `NotFoundError` (or reorder into the wrong place).
141
+ //
142
+ // These overrides forward the operation to wherever the node actually lives
143
+ // (the slot), so reconciler operations succeed regardless of the relocation.
144
+ // For removal/insertion/replacement we key off the target node's real
145
+ // `parentNode`, which also works while the slot is portaled to `document.body`
146
+ // (open modal/panel/popover). Internal chrome mutations use {@link adopt} to
147
+ // bypass this forwarding; `mount()` itself never routes through here (it uses
148
+ // `this.innerHTML` and operates on the slot/fragment nodes directly).
149
+ appendChild(node) {
150
+ const slot = this.#slotTarget();
151
+ return slot ? slot.appendChild(node) : super.appendChild(node);
152
+ }
153
+ insertBefore(node, child) {
154
+ if (child && child.parentNode && child.parentNode !== this) {
155
+ return child.parentNode.insertBefore(node, child);
156
+ }
157
+ const slot = this.#slotTarget();
158
+ return slot ? slot.insertBefore(node, child) : super.insertBefore(node, child);
159
+ }
160
+ removeChild(child) {
161
+ if (child.parentNode && child.parentNode !== this) {
162
+ return child.parentNode.removeChild(child);
163
+ }
164
+ return super.removeChild(child);
165
+ }
166
+ replaceChild(node, child) {
167
+ if (child.parentNode && child.parentNode !== this) {
168
+ return child.parentNode.replaceChild(node, child);
169
+ }
170
+ return super.replaceChild(node, child);
171
+ }
172
+ /**
173
+ * Append an element the component owns (chrome / portaled nodes) as a direct
174
+ * host child, bypassing the slot-forwarding {@link appendChild} override.
175
+ * Portal-based components use this to re-home a portaled node before a
176
+ * re-render rebuilds the chrome.
177
+ */
178
+ adopt(node) {
179
+ super.appendChild(node);
180
+ }
88
181
  /** Read a string attribute with a fallback. */
89
182
  attr(name, fallback = "") {
90
183
  return this.getAttribute(name) ?? fallback;
@@ -156,8 +249,12 @@ function wireLocaleObserver() {
156
249
  function escapeHtml(value) {
157
250
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
158
251
  }
252
+ var SAFE_CSS_COLOR = /^[a-zA-Z0-9#(),.%\s-]+$/;
253
+ function cssColor(value, fallback = "") {
254
+ return value && SAFE_CSS_COLOR.test(value) ? value : fallback;
255
+ }
159
256
  function classes(...parts) {
160
- return parts.filter(Boolean).join(" ");
257
+ return escapeHtml(parts.filter(Boolean).join(" "));
161
258
  }
162
259
  function reflectControlValue(control, value) {
163
260
  if (control && control.value !== value) control.value = value;
@@ -851,8 +948,8 @@ var OdyCard = class extends OdyElement {
851
948
  static observedAttributes = ["bar-color", "clickable", "no-bar"];
852
949
  render() {
853
950
  const noBar = this.flag("no-bar");
854
- const barColor = this.attr("bar-color");
855
- const barStyle = barColor ? ` style="background-color:${this.esc(barColor)}"` : "";
951
+ const barColor = cssColor(this.attr("bar-color"));
952
+ const barStyle = barColor ? ` style="background-color:${barColor}"` : "";
856
953
  const cls = classes(
857
954
  "ody-card__container",
858
955
  this.flag("clickable") && "ody-card--clickable",
@@ -918,7 +1015,7 @@ var OdyLoadingBar = class extends OdyElement {
918
1015
  render() {
919
1016
  const raw = Number.parseFloat(this.attr("value", "0"));
920
1017
  const value = Number.isFinite(raw) ? Math.min(100, Math.max(0, raw)) : 0;
921
- const color = this.attr("color") || "var(--color-interaction-300)";
1018
+ const color = cssColor(this.attr("color"), "var(--color-interaction-300)");
922
1019
  const label = this.attr("label");
923
1020
  const text = label ? `<div class="loading-bar__text-container"><span>${this.esc(label)}</span><span>${value}%</span></div>` : "";
924
1021
  this.mount(
@@ -1121,11 +1218,11 @@ var OdyProductIndicator = class extends OdyElement {
1121
1218
  `ody-product-indicator-container--size-${size}`,
1122
1219
  clickable && "ody-product-indicator-clickable"
1123
1220
  );
1124
- const barColor = this.attr("bar-color");
1125
- const barStyle = barColor ? ` style="background-color:${this.esc(barColor)}"` : "";
1221
+ const barColor = cssColor(this.attr("bar-color"));
1222
+ const barStyle = barColor ? ` style="background-color:${barColor}"` : "";
1126
1223
  const bar = `<div class="ody-product-indicator__bar"${barStyle}></div>`;
1127
- const textColor = this.attr("text-color");
1128
- const textStyle = textColor ? ` style="color:${this.esc(textColor)}"` : "";
1224
+ const textColor = cssColor(this.attr("text-color"));
1225
+ const textStyle = textColor ? ` style="color:${textColor}"` : "";
1129
1226
  const name = this.attr("name");
1130
1227
  const nameEl = `<div class="ody-product-indicator__name"${textStyle}>${this.esc(name)}</div>`;
1131
1228
  const detail = this.attr("detail");
@@ -1141,6 +1238,16 @@ define("ody-product-indicator", OdyProductIndicator);
1141
1238
  // src/ui/components/toggle-button.ts
1142
1239
  var OdyToggleButton = class extends OdyElement {
1143
1240
  static observedAttributes = ["options", "selected", "size", "disabled"];
1241
+ /** Options set via the JS property; when set it wins over the attribute. */
1242
+ #options = null;
1243
+ /** Options: the JS property wins, else the JSON `options` attribute. */
1244
+ get options() {
1245
+ return this.#options ?? this.#parseOptions();
1246
+ }
1247
+ set options(next) {
1248
+ this.#options = coerceToggleOptions(next);
1249
+ if (this.querySelector(".ody-toggle-button")) this.render();
1250
+ }
1144
1251
  connectedCallback() {
1145
1252
  super.connectedCallback();
1146
1253
  this.addEventListener("click", this.#handleClick);
@@ -1172,7 +1279,7 @@ var OdyToggleButton = class extends OdyElement {
1172
1279
  const size = this.attr("size", "base");
1173
1280
  const selected = this.attr("selected");
1174
1281
  const groupDisabled = this.flag("disabled");
1175
- const buttons = this.#parseOptions().map((opt) => {
1282
+ const buttons = this.options.map((opt) => {
1176
1283
  const isSelected = selected !== "" && selected === opt.value;
1177
1284
  const isDisabled = groupDisabled || Boolean(opt.disabled);
1178
1285
  const cls = classes(
@@ -1190,6 +1297,18 @@ var OdyToggleButton = class extends OdyElement {
1190
1297
  this.mount(`<div class="ody-toggle-button" aria-label="${this.localized("aria-label", "toggleGroup")}">${buttons}</div>`);
1191
1298
  }
1192
1299
  };
1300
+ function coerceToggleOptions(next) {
1301
+ if (Array.isArray(next)) return next;
1302
+ if (typeof next === "string" && next) {
1303
+ try {
1304
+ const parsed = JSON.parse(next);
1305
+ return Array.isArray(parsed) ? parsed : [];
1306
+ } catch {
1307
+ return [];
1308
+ }
1309
+ }
1310
+ return [];
1311
+ }
1193
1312
  define("ody-toggle-button", OdyToggleButton);
1194
1313
 
1195
1314
  // src/ui/components/section.ts
@@ -1762,6 +1881,20 @@ var OdyMoneyInput = class extends OdyElement {
1762
1881
  get precision() {
1763
1882
  return CURRENCY_PRECISION[this.attr("currency", "USD")] ?? DEFAULT_PRECISION;
1764
1883
  }
1884
+ /**
1885
+ * Reflect a `value` change into the live control in place instead of a full
1886
+ * re-render, so a framework-controlled `value` binding never drops focus or
1887
+ * caret while the user is typing. Other observed attributes change the chrome
1888
+ * and still re-render via the base implementation.
1889
+ */
1890
+ attributeChangedCallback(name, oldValue, newValue) {
1891
+ if (name === "value") {
1892
+ if (oldValue === newValue) return;
1893
+ reflectControlValue(this.querySelector(".ody-input__field"), newValue ?? "");
1894
+ return;
1895
+ }
1896
+ super.attributeChangedCallback();
1897
+ }
1765
1898
  render() {
1766
1899
  const size = this.attr("size", "base");
1767
1900
  const isReadonly = this.flag("readonly");
@@ -1785,11 +1918,15 @@ var OdyMoneyInput = class extends OdyElement {
1785
1918
  const input = this.querySelector(".ody-input__field");
1786
1919
  input?.addEventListener("input", this.#onInput);
1787
1920
  input?.addEventListener("blur", this.#onBlur);
1921
+ input?.addEventListener("change", this.#onNativeChange);
1788
1922
  }
1923
+ #onNativeChange = (event) => {
1924
+ event.stopPropagation();
1925
+ };
1789
1926
  #onInput = (event) => {
1790
1927
  event.stopPropagation();
1791
1928
  const value = event.target.value;
1792
- this.#value = value;
1929
+ this.value = value;
1793
1930
  this.dispatchEvent(new CustomEvent("input", { detail: { value }, bubbles: true }));
1794
1931
  };
1795
1932
  #onBlur = (event) => {
@@ -1825,6 +1962,20 @@ var OdyPercentageInput = class extends OdyElement {
1825
1962
  this.#value = next;
1826
1963
  this.setAttribute("value", next);
1827
1964
  }
1965
+ /**
1966
+ * Reflect a `value` change into the live control in place instead of a full
1967
+ * re-render, so a framework-controlled `value` binding never drops focus or
1968
+ * caret while the user is typing. Other observed attributes change the chrome
1969
+ * and still re-render via the base implementation.
1970
+ */
1971
+ attributeChangedCallback(name, oldValue, newValue) {
1972
+ if (name === "value") {
1973
+ if (oldValue === newValue) return;
1974
+ reflectControlValue(this.querySelector(".ody-input__field"), newValue ?? "");
1975
+ return;
1976
+ }
1977
+ super.attributeChangedCallback();
1978
+ }
1828
1979
  render() {
1829
1980
  const size = this.attr("size", "base");
1830
1981
  const isReadonly = this.flag("readonly");
@@ -1847,7 +1998,11 @@ var OdyPercentageInput = class extends OdyElement {
1847
1998
  input?.addEventListener("keypress", this.#onKeypress);
1848
1999
  input?.addEventListener("input", this.#onInput);
1849
2000
  input?.addEventListener("blur", this.#onBlur);
2001
+ input?.addEventListener("change", this.#onNativeChange);
1850
2002
  }
2003
+ #onNativeChange = (event) => {
2004
+ event.stopPropagation();
2005
+ };
1851
2006
  #onKeypress = (event) => {
1852
2007
  if (NON_NUMERIC_KEY.test(event.key)) {
1853
2008
  event.preventDefault();
@@ -1863,7 +2018,7 @@ var OdyPercentageInput = class extends OdyElement {
1863
2018
  #onInput = (event) => {
1864
2019
  event.stopPropagation();
1865
2020
  const value = event.target.value;
1866
- this.#value = value;
2021
+ this.value = value;
1867
2022
  this.dispatchEvent(new CustomEvent("input", { detail: { value }, bubbles: true }));
1868
2023
  };
1869
2024
  #onBlur = (event) => {
@@ -1883,6 +2038,26 @@ var OdyPercentageInput = class extends OdyElement {
1883
2038
  };
1884
2039
  define("ody-percentage-input", OdyPercentageInput);
1885
2040
 
2041
+ // src/ui/checkbox-state.ts
2042
+ function applyCheckboxState(root, checked, indeterminate) {
2043
+ if (!root) return;
2044
+ root.classList.toggle("ody-checkbox--checked", checked);
2045
+ const input = root.querySelector(".ody-checkbox__input");
2046
+ if (input) {
2047
+ input.checked = checked;
2048
+ input.indeterminate = indeterminate;
2049
+ }
2050
+ const box = root.querySelector(".ody-checkbox__box");
2051
+ box?.querySelector(".ody-checkbox__mark")?.remove();
2052
+ const icon = indeterminate ? "minus" : checked ? "check" : "";
2053
+ if (icon && box) {
2054
+ const mark = document.createElement("span");
2055
+ mark.className = "ody-checkbox__mark";
2056
+ mark.innerHTML = iconSvg(icon, "icon__svg");
2057
+ box.appendChild(mark);
2058
+ }
2059
+ }
2060
+
1886
2061
  // src/ui/components/checkbox.ts
1887
2062
  var OdyCheckbox = class extends OdyElement {
1888
2063
  static observedAttributes = ["label", "size", "checked", "indeterminate", "disabled"];
@@ -1901,6 +2076,27 @@ var OdyCheckbox = class extends OdyElement {
1901
2076
  set value(next) {
1902
2077
  this.checked = next;
1903
2078
  }
2079
+ /**
2080
+ * Reflect `checked` / `indeterminate` in place instead of a full re-render, so
2081
+ * selecting the box (mouse or keyboard) never rebuilds the native `<input>`
2082
+ * and drops focus. Other observed attributes still re-render via the base.
2083
+ */
2084
+ attributeChangedCallback(name, oldValue, newValue) {
2085
+ if ((name === "checked" || name === "indeterminate") && this.querySelector(".ody-checkbox__input")) {
2086
+ if (oldValue === newValue) return;
2087
+ this.#syncChecked();
2088
+ return;
2089
+ }
2090
+ super.attributeChangedCallback();
2091
+ }
2092
+ /** Update the live control, wrapper class, and check/minus mark in place. */
2093
+ #syncChecked() {
2094
+ applyCheckboxState(
2095
+ this.querySelector(".ody-checkbox"),
2096
+ this.flag("checked"),
2097
+ this.flag("indeterminate")
2098
+ );
2099
+ }
1904
2100
  render() {
1905
2101
  const size = this.attr("size", "small");
1906
2102
  const isChecked = this.flag("checked");
@@ -1934,6 +2130,8 @@ var groupSeq = 0;
1934
2130
  var OdyRadioButtonGroup = class extends OdyElement {
1935
2131
  static observedAttributes = ["options", "value", "size", "disabled"];
1936
2132
  #name = `ody-radio-button-group-${groupSeq += 1}`;
2133
+ /** Options set via the JS property; when set it wins over the attribute. */
2134
+ #options = null;
1937
2135
  /** The selected option value. */
1938
2136
  get value() {
1939
2137
  return this.attr("value");
@@ -1941,9 +2139,29 @@ var OdyRadioButtonGroup = class extends OdyElement {
1941
2139
  set value(next) {
1942
2140
  this.setAttribute("value", next);
1943
2141
  }
1944
- /** Parsed `options`; malformed JSON yields an empty list. */
2142
+ /** Options: the JS property wins, else the JSON `options` attribute. */
1945
2143
  get options() {
1946
- return parseOptions(this.attr("options"));
2144
+ return this.#options ?? parseOptions(this.attr("options"));
2145
+ }
2146
+ set options(next) {
2147
+ this.#options = coerceOptions(next);
2148
+ if (this.querySelector(".ody-radio-button-group")) this.render();
2149
+ }
2150
+ /**
2151
+ * Reflect a `value` change by re-checking the matching radio in place instead
2152
+ * of rebuilding the fieldset — a full re-render would replace the focused
2153
+ * radio and break keyboard (arrow-key) navigation between options.
2154
+ */
2155
+ attributeChangedCallback(name, oldValue, newValue) {
2156
+ if (name === "value" && this.querySelector(".ody-radio-button-input__field")) {
2157
+ if (oldValue === newValue) return;
2158
+ const selected = this.value;
2159
+ for (const input of this.querySelectorAll(".ody-radio-button-input__field")) {
2160
+ input.checked = input.value === selected;
2161
+ }
2162
+ return;
2163
+ }
2164
+ super.attributeChangedCallback();
1947
2165
  }
1948
2166
  render() {
1949
2167
  const size = this.attr("size", "base");
@@ -1967,6 +2185,11 @@ var OdyRadioButtonGroup = class extends OdyElement {
1967
2185
  this.dispatchEvent(new CustomEvent("change", { detail: { value }, bubbles: true }));
1968
2186
  };
1969
2187
  };
2188
+ function coerceOptions(next) {
2189
+ if (Array.isArray(next)) return next;
2190
+ if (typeof next === "string") return parseOptions(next);
2191
+ return [];
2192
+ }
1970
2193
  function parseOptions(raw) {
1971
2194
  if (!raw) return [];
1972
2195
  try {
@@ -1982,17 +2205,63 @@ define("ody-radio-button-group", OdyRadioButtonGroup);
1982
2205
  // src/ui/components/checkbox-group.ts
1983
2206
  var OdyCheckboxGroup = class extends OdyElement {
1984
2207
  static observedAttributes = ["options", "value", "select-all-label", "size", "disabled"];
1985
- /** The selected option values. */
2208
+ /** Options set via the JS property; when set it wins over the attribute. */
2209
+ #options = null;
2210
+ /**
2211
+ * The selected option values. The reflected `value` attribute is serialized
2212
+ * as a JSON array so values containing commas round-trip; a legacy
2213
+ * comma-separated attribute is still read for backwards compatibility.
2214
+ */
1986
2215
  get value() {
1987
2216
  const raw = this.attr("value");
1988
- return raw ? raw.split(",").filter(Boolean) : [];
2217
+ if (!raw) return [];
2218
+ try {
2219
+ const parsed = JSON.parse(raw);
2220
+ if (Array.isArray(parsed)) return parsed.map(String);
2221
+ } catch {
2222
+ }
2223
+ return raw.split(",").filter(Boolean);
1989
2224
  }
1990
2225
  set value(next) {
1991
- this.setAttribute("value", next.join(","));
2226
+ this.setAttribute("value", JSON.stringify(Array.isArray(next) ? next : []));
1992
2227
  }
1993
- /** Parsed `options`; malformed JSON yields an empty list. */
2228
+ /** Options: the JS property wins, else the JSON `options` attribute. */
1994
2229
  get options() {
1995
- return parseOptions2(this.attr("options"));
2230
+ return this.#options ?? parseOptions2(this.attr("options"));
2231
+ }
2232
+ /**
2233
+ * Accepts an array of `{ label, value }` (or a JSON string) and stores it as
2234
+ * the source of truth. Mirrors `<ody-dropdown-*>` so `el.options = [...]`
2235
+ * works, without reflecting a large JSON blob onto the DOM attribute.
2236
+ */
2237
+ set options(next) {
2238
+ this.#options = coerceOptions2(next);
2239
+ if (this.querySelector(".ody-checkbox-group")) this.render();
2240
+ }
2241
+ /**
2242
+ * Reflect a `value` change by updating each checkbox (and the select-all
2243
+ * parent) in place, so toggling an option keeps focus instead of rebuilding
2244
+ * the group. Other observed attributes still re-render via the base.
2245
+ */
2246
+ attributeChangedCallback(name, oldValue, newValue) {
2247
+ if (name === "value" && this.querySelector(".ody-checkbox-group")) {
2248
+ if (oldValue === newValue) return;
2249
+ this.#syncSelection();
2250
+ return;
2251
+ }
2252
+ super.attributeChangedCallback();
2253
+ }
2254
+ /** Recompute checked/indeterminate state for every checkbox in place. */
2255
+ #syncSelection() {
2256
+ const options = this.options;
2257
+ const selected = new Set(this.value);
2258
+ const allChecked = options.length > 0 && options.every((o) => selected.has(o.value));
2259
+ const someChecked = !allChecked && options.some((o) => selected.has(o.value));
2260
+ for (const cb of this.querySelectorAll(".ody-checkbox-group__item .ody-checkbox")) {
2261
+ const input = cb.querySelector(".ody-checkbox__input");
2262
+ applyCheckboxState(cb, input ? selected.has(input.value) : false, false);
2263
+ }
2264
+ applyCheckboxState(this.querySelector(".ody-checkbox-group__select-all"), allChecked, someChecked);
1996
2265
  }
1997
2266
  render() {
1998
2267
  const size = this.attr("size", "small");
@@ -2058,6 +2327,11 @@ var OdyCheckboxGroup = class extends OdyElement {
2058
2327
  this.dispatchEvent(new CustomEvent("change", { detail: { value: this.value }, bubbles: true }));
2059
2328
  }
2060
2329
  };
2330
+ function coerceOptions2(next) {
2331
+ if (Array.isArray(next)) return next;
2332
+ if (typeof next === "string") return parseOptions2(next);
2333
+ return [];
2334
+ }
2061
2335
  function parseOptions2(raw) {
2062
2336
  if (!raw) return [];
2063
2337
  try {
@@ -2237,6 +2511,11 @@ var OdyCopyButton = class extends OdyElement {
2237
2511
  if (this.isConnected) this.render();
2238
2512
  }, Number.isFinite(duration) ? duration : 1200);
2239
2513
  }
2514
+ disconnectedCallback() {
2515
+ super.disconnectedCallback();
2516
+ if (this.#timer) clearTimeout(this.#timer);
2517
+ this.#timer = void 0;
2518
+ }
2240
2519
  };
2241
2520
  define("ody-copy-button", OdyCopyButton);
2242
2521
 
@@ -2446,7 +2725,7 @@ var OdyModal = class extends OdyElement {
2446
2725
  this.dispatchEvent(new CustomEvent(EVENT_CLOSE, { bubbles: true }));
2447
2726
  }
2448
2727
  render() {
2449
- if (this.#dialog) this.appendChild(this.#dialog);
2728
+ if (this.#dialog) this.adopt(this.#dialog);
2450
2729
  if (this.#backdrop) removePortal(this.#backdrop);
2451
2730
  const isOpen = this.flag("open");
2452
2731
  const size = this.attr("size", "base");
@@ -2479,6 +2758,7 @@ var OdyModal = class extends OdyElement {
2479
2758
  disconnectedCallback() {
2480
2759
  super.disconnectedCallback();
2481
2760
  document.removeEventListener("keydown", this.#onKeydown);
2761
+ this.reclaimPortaledSlot(this.#dialog);
2482
2762
  if (this.#backdrop) removePortal(this.#backdrop);
2483
2763
  if (this.#dialog) removePortal(this.#dialog);
2484
2764
  this.#backdrop = null;
@@ -2541,7 +2821,7 @@ var OdyPopover = class extends OdyElement {
2541
2821
  if (this.#visible) this.close();
2542
2822
  this.#trigger?.removeEventListener("click", this.#onTriggerClick);
2543
2823
  if (this.#panel) {
2544
- this.appendChild(this.#panel);
2824
+ this.adopt(this.#panel);
2545
2825
  this.#panel.classList.remove("ody-popover__container--open");
2546
2826
  }
2547
2827
  const trigger = this.querySelector("[data-ody-popover-trigger-slot]")?.firstElementChild;
@@ -2572,6 +2852,7 @@ var OdyPopover = class extends OdyElement {
2572
2852
  super.disconnectedCallback();
2573
2853
  document.removeEventListener("click", this.#onOutsideClick, true);
2574
2854
  this.#trigger?.removeEventListener("click", this.#onTriggerClick);
2855
+ this.reclaimPortaledSlot(this.#panel);
2575
2856
  if (this.#panel) removePortal(this.#panel);
2576
2857
  this.#panel = null;
2577
2858
  this.#trigger = null;
@@ -2616,7 +2897,7 @@ var OdyTooltip = class extends OdyElement {
2616
2897
  if (this.#visible) this.hide();
2617
2898
  this.#unbind();
2618
2899
  if (this.#bubble) {
2619
- this.appendChild(this.#bubble);
2900
+ this.adopt(this.#bubble);
2620
2901
  this.#bubble.classList.remove("ody-tooltip--visible");
2621
2902
  }
2622
2903
  const slotted = this.querySelector("[data-ody-tooltip-trigger-slot]")?.firstElementChild;
@@ -2658,6 +2939,7 @@ var OdyTooltip = class extends OdyElement {
2658
2939
  disconnectedCallback() {
2659
2940
  super.disconnectedCallback();
2660
2941
  this.#unbind();
2942
+ this.reclaimPortaledSlot(this.#bubble);
2661
2943
  if (this.#bubble) removePortal(this.#bubble);
2662
2944
  this.#bubble = null;
2663
2945
  this.#trigger = null;
@@ -2681,7 +2963,7 @@ var OdyPanel = class extends OdyElement {
2681
2963
  this.dispatchEvent(new CustomEvent(EVENT_CLOSE3, { bubbles: true }));
2682
2964
  }
2683
2965
  render() {
2684
- if (this.#panel) this.appendChild(this.#panel);
2966
+ if (this.#panel) this.adopt(this.#panel);
2685
2967
  if (this.#overlay) removePortal(this.#overlay);
2686
2968
  const isOpen = this.flag("open");
2687
2969
  const heading = this.attr("heading");
@@ -2702,6 +2984,7 @@ var OdyPanel = class extends OdyElement {
2702
2984
  };
2703
2985
  disconnectedCallback() {
2704
2986
  super.disconnectedCallback();
2987
+ this.reclaimPortaledSlot(this.#panel);
2705
2988
  if (this.#panel) removePortal(this.#panel);
2706
2989
  if (this.#overlay) removePortal(this.#overlay);
2707
2990
  this.#panel = null;
@@ -2721,6 +3004,8 @@ var HOST_TAG = "ody-toast-host";
2721
3004
  var DEFAULT_TIMEOUT = 4e3;
2722
3005
  var REMOVE_DELAY = 400;
2723
3006
  var OdyToastHost = class extends OdyElement {
3007
+ /** Per-toast cleanups (cancel rAF + auto-dismiss timer) run on host teardown. */
3008
+ #pending = /* @__PURE__ */ new Set();
2724
3009
  render() {
2725
3010
  this.mount(`<div class="toast-notification-container" data-ody-slot></div>`);
2726
3011
  }
@@ -2735,17 +3020,28 @@ var OdyToastHost = class extends OdyElement {
2735
3020
  const title = options.title ? `<h1 class="toast-notification__title">${this.esc(options.title)}</h1>` : "";
2736
3021
  el.innerHTML = `<div class="toast-notification__header__container"><div class="toast-notification__header"><span class="toast-notification__icon icon">${iconSvg(VARIANT_ICON3[variant], "icon__svg")}</span>` + title + `</div><button type="button" class="toast-notification__close-button" aria-label="${this.localized("close-label", "close")}">${iconSvg("close", "icon__svg")}</button></div><div class="toast-notification__body__container"><p class="toast-notification__body">${this.esc(message)}</p></div>`;
2737
3022
  container.appendChild(el);
2738
- requestAnimationFrame(() => el.classList.add("toast-notification--visible"));
3023
+ const raf = requestAnimationFrame(() => el.classList.add("toast-notification--visible"));
2739
3024
  let timer;
2740
- const dismiss = () => {
3025
+ const cleanup = () => {
3026
+ cancelAnimationFrame(raf);
2741
3027
  if (timer !== void 0) clearTimeout(timer);
3028
+ this.#pending.delete(cleanup);
3029
+ };
3030
+ const dismiss = () => {
3031
+ cleanup();
2742
3032
  el.classList.remove("toast-notification--visible");
2743
3033
  setTimeout(() => el.remove(), REMOVE_DELAY);
2744
3034
  };
2745
3035
  el.querySelector(".toast-notification__close-button")?.addEventListener("click", dismiss);
2746
3036
  if (timeout > 0) timer = setTimeout(dismiss, timeout);
3037
+ this.#pending.add(cleanup);
2747
3038
  return { dismiss };
2748
3039
  }
3040
+ disconnectedCallback() {
3041
+ super.disconnectedCallback();
3042
+ for (const cleanup of [...this.#pending]) cleanup();
3043
+ this.#pending.clear();
3044
+ }
2749
3045
  };
2750
3046
  define(HOST_TAG, OdyToastHost);
2751
3047
  var hostEl = null;
@@ -2802,6 +3098,10 @@ var OdySelectBase = class extends OdyElement {
2802
3098
  if (!q) return this.options;
2803
3099
  return this.options.filter((o) => o.label.toLowerCase().includes(q));
2804
3100
  }
3101
+ connectedCallback() {
3102
+ super.connectedCallback();
3103
+ if (this.open) document.addEventListener("click", this.#onOutsideClick, true);
3104
+ }
2805
3105
  // ---- Rendering -----------------------------------------------------------
2806
3106
  render() {
2807
3107
  const listId = `${this.uid}-listbox`;
@@ -3169,10 +3469,34 @@ var OdyDatePicker = class extends OdyElement {
3169
3469
  "presets",
3170
3470
  "display-format"
3171
3471
  ];
3472
+ /** Presets set via the JS property; when set it wins over the attribute. */
3473
+ #presets = null;
3474
+ #isDateDisallowed;
3475
+ #formatDate;
3476
+ /** Preset list: the JS property wins, else the JSON `presets` attribute. */
3477
+ get presets() {
3478
+ return this.#presets ?? this.#parsePresets();
3479
+ }
3480
+ set presets(next) {
3481
+ this.#presets = coerceDatePresets(next);
3482
+ if (this.querySelector(".ody-datepicker__trigger")) this.render();
3483
+ }
3172
3484
  /** Disable arbitrary days; not an attribute (functions can't serialize). */
3173
- isDateDisallowed;
3485
+ get isDateDisallowed() {
3486
+ return this.#isDateDisallowed;
3487
+ }
3488
+ set isDateDisallowed(fn) {
3489
+ this.#isDateDisallowed = fn;
3490
+ if (this.querySelector(".ody-datepicker__trigger")) this.render();
3491
+ }
3174
3492
  /** Custom trigger-label formatter; wins over `display-format`/`Intl`. */
3175
- formatDate;
3493
+ get formatDate() {
3494
+ return this.#formatDate;
3495
+ }
3496
+ set formatDate(fn) {
3497
+ this.#formatDate = fn;
3498
+ if (this.querySelector(".ody-datepicker__trigger")) this.render();
3499
+ }
3176
3500
  #open = false;
3177
3501
  /** First day of the currently displayed month (local). */
3178
3502
  #viewDate = /* @__PURE__ */ new Date();
@@ -3503,7 +3827,7 @@ var OdyDatePicker = class extends OdyElement {
3503
3827
  return rows.join("");
3504
3828
  }
3505
3829
  #renderPresets() {
3506
- const presets = this.#parsePresets();
3830
+ const presets = this.presets;
3507
3831
  if (presets.length === 0) return "";
3508
3832
  const buttons = presets.map(
3509
3833
  (p) => `<button type="button" class="ody-datepicker__preset" data-value="${this.esc(p.value)}">${this.esc(p.label)}</button>`
@@ -3545,6 +3869,18 @@ var OdyDatePicker = class extends OdyElement {
3545
3869
  document.removeEventListener("click", this.#onOutsideClick, true);
3546
3870
  }
3547
3871
  };
3872
+ function coerceDatePresets(next) {
3873
+ if (Array.isArray(next)) return next;
3874
+ if (typeof next === "string" && next) {
3875
+ try {
3876
+ const parsed = JSON.parse(next);
3877
+ return Array.isArray(parsed) ? parsed : [];
3878
+ } catch {
3879
+ return [];
3880
+ }
3881
+ }
3882
+ return [];
3883
+ }
3548
3884
  define("ody-datepicker", OdyDatePicker);
3549
3885
 
3550
3886
  // src/ui/components/table.ts