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