@colletdev/core 0.1.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 (119) hide show
  1. package/README.md +77 -0
  2. package/custom-elements.json +6037 -0
  3. package/generated/.gitattributes +2 -0
  4. package/generated/index.d.ts +120 -0
  5. package/generated/index.js +521 -0
  6. package/generated/styles.js +2845 -0
  7. package/package.json +56 -0
  8. package/src/elements/accordion.d.ts +20 -0
  9. package/src/elements/accordion.js +92 -0
  10. package/src/elements/activity_group.d.ts +19 -0
  11. package/src/elements/activity_group.js +27 -0
  12. package/src/elements/alert.d.ts +24 -0
  13. package/src/elements/alert.js +40 -0
  14. package/src/elements/autocomplete.d.ts +30 -0
  15. package/src/elements/autocomplete.js +671 -0
  16. package/src/elements/avatar.d.ts +18 -0
  17. package/src/elements/avatar.js +28 -0
  18. package/src/elements/backdrop.d.ts +14 -0
  19. package/src/elements/backdrop.js +28 -0
  20. package/src/elements/badge.d.ts +21 -0
  21. package/src/elements/badge.js +42 -0
  22. package/src/elements/breadcrumb.d.ts +17 -0
  23. package/src/elements/breadcrumb.js +41 -0
  24. package/src/elements/button.d.ts +24 -0
  25. package/src/elements/button.js +36 -0
  26. package/src/elements/card.d.ts +21 -0
  27. package/src/elements/card.js +67 -0
  28. package/src/elements/carousel.d.ts +23 -0
  29. package/src/elements/carousel.js +895 -0
  30. package/src/elements/chat_input.d.ts +22 -0
  31. package/src/elements/chat_input.js +78 -0
  32. package/src/elements/checkbox.d.ts +21 -0
  33. package/src/elements/checkbox.js +114 -0
  34. package/src/elements/code_block.d.ts +21 -0
  35. package/src/elements/code_block.js +27 -0
  36. package/src/elements/collapsible.d.ts +20 -0
  37. package/src/elements/collapsible.js +93 -0
  38. package/src/elements/date_picker.d.ts +30 -0
  39. package/src/elements/date_picker.js +528 -0
  40. package/src/elements/dialog.d.ts +20 -0
  41. package/src/elements/dialog.js +314 -0
  42. package/src/elements/drawer.d.ts +20 -0
  43. package/src/elements/drawer.js +318 -0
  44. package/src/elements/fab.d.ts +22 -0
  45. package/src/elements/fab.js +36 -0
  46. package/src/elements/file_upload.d.ts +26 -0
  47. package/src/elements/file_upload.js +59 -0
  48. package/src/elements/listbox.d.ts +19 -0
  49. package/src/elements/listbox.js +250 -0
  50. package/src/elements/menu.d.ts +20 -0
  51. package/src/elements/menu.js +224 -0
  52. package/src/elements/message_bubble.d.ts +23 -0
  53. package/src/elements/message_bubble.js +29 -0
  54. package/src/elements/message_group.d.ts +18 -0
  55. package/src/elements/message_group.js +28 -0
  56. package/src/elements/message_part.d.ts +35 -0
  57. package/src/elements/message_part.js +153 -0
  58. package/src/elements/pagination.d.ts +22 -0
  59. package/src/elements/pagination.js +36 -0
  60. package/src/elements/popover.d.ts +26 -0
  61. package/src/elements/popover.js +191 -0
  62. package/src/elements/profile_menu.d.ts +20 -0
  63. package/src/elements/profile_menu.js +213 -0
  64. package/src/elements/progress.d.ts +18 -0
  65. package/src/elements/progress.js +31 -0
  66. package/src/elements/radio_group.d.ts +22 -0
  67. package/src/elements/radio_group.js +70 -0
  68. package/src/elements/scrollbar.d.ts +19 -0
  69. package/src/elements/scrollbar.js +299 -0
  70. package/src/elements/search_bar.d.ts +27 -0
  71. package/src/elements/search_bar.js +98 -0
  72. package/src/elements/select.d.ts +26 -0
  73. package/src/elements/select.js +485 -0
  74. package/src/elements/sidebar.d.ts +21 -0
  75. package/src/elements/sidebar.js +322 -0
  76. package/src/elements/skeleton.d.ts +17 -0
  77. package/src/elements/skeleton.js +31 -0
  78. package/src/elements/slider.d.ts +28 -0
  79. package/src/elements/slider.js +93 -0
  80. package/src/elements/speed_dial.d.ts +23 -0
  81. package/src/elements/speed_dial.js +370 -0
  82. package/src/elements/spinner.d.ts +15 -0
  83. package/src/elements/spinner.js +28 -0
  84. package/src/elements/split_button.d.ts +23 -0
  85. package/src/elements/split_button.js +281 -0
  86. package/src/elements/stepper.d.ts +20 -0
  87. package/src/elements/stepper.js +31 -0
  88. package/src/elements/switch.d.ts +22 -0
  89. package/src/elements/switch.js +129 -0
  90. package/src/elements/table.d.ts +29 -0
  91. package/src/elements/table.js +371 -0
  92. package/src/elements/tabs.d.ts +19 -0
  93. package/src/elements/tabs.js +139 -0
  94. package/src/elements/text.d.ts +26 -0
  95. package/src/elements/text.js +32 -0
  96. package/src/elements/text_input.d.ts +36 -0
  97. package/src/elements/text_input.js +121 -0
  98. package/src/elements/thinking.d.ts +17 -0
  99. package/src/elements/thinking.js +28 -0
  100. package/src/elements/toast.d.ts +23 -0
  101. package/src/elements/toast.js +209 -0
  102. package/src/elements/toggle_group.d.ts +22 -0
  103. package/src/elements/toggle_group.js +176 -0
  104. package/src/elements/tooltip.d.ts +18 -0
  105. package/src/elements/tooltip.js +64 -0
  106. package/src/markdown.d.ts +24 -0
  107. package/src/markdown.js +66 -0
  108. package/src/runtime.d.ts +35 -0
  109. package/src/runtime.js +790 -0
  110. package/src/server.d.ts +69 -0
  111. package/src/server.js +176 -0
  112. package/src/streaming-markdown.js +43 -0
  113. package/src/vite-plugin.d.ts +46 -0
  114. package/src/vite-plugin.js +221 -0
  115. package/wasm/package.json +16 -0
  116. package/wasm/wasm_api.d.ts +72 -0
  117. package/wasm/wasm_api.js +593 -0
  118. package/wasm/wasm_api_bg.wasm +0 -0
  119. package/wasm/wasm_api_bg.wasm.d.ts +10 -0
package/src/runtime.js ADDED
@@ -0,0 +1,790 @@
1
+ // Collet Core Runtime — WASM init, shared styles, base Custom Element class.
2
+ //
3
+ // This is the platform layer. Every Collet Custom Element extends CxElement.
4
+ // Key design decisions:
5
+ // 1. adoptedStyleSheets shares ONE parsed CSS across ALL Shadow Roots
6
+ // 2. queueMicrotask batches multiple attribute changes into one WASM render
7
+ // 3. Attribute names are kebab-case (HTML standard), converted to snake_case
8
+ // for Rust serde deserialization automatically
9
+ // 4. Form-associated elements use ElementInternals for native <form> participation
10
+ // 5. Custom Events with composed:true bubble out of Shadow DOM for framework binding
11
+
12
+ // ─── WASM Singleton ───
13
+ // One promise, resolved once. All elements share the same WASM instance.
14
+
15
+ let wasmReady;
16
+ let wasmExports;
17
+
18
+ // Elements that connect before WASM is ready queue here.
19
+ // When WASM loads, they re-render automatically.
20
+ const _pendingElements = new Set();
21
+
22
+ export function initWasm(initFn) {
23
+ if (!wasmReady) {
24
+ wasmReady = initFn().then(exports => {
25
+ wasmExports = exports;
26
+ // Flush elements that tried to render before WASM was ready
27
+ for (const el of _pendingElements) {
28
+ if (el.isConnected) el._scheduleRender();
29
+ }
30
+ _pendingElements.clear();
31
+ return exports;
32
+ }).catch(err => {
33
+ // Reset so fallback can retry with a different URL
34
+ wasmReady = null;
35
+ throw err;
36
+ });
37
+ }
38
+ return wasmReady;
39
+ }
40
+
41
+ export function getWasmExports() {
42
+ return wasmExports;
43
+ }
44
+
45
+ export function isWasmReady() {
46
+ return !!wasmExports;
47
+ }
48
+
49
+ // ─── WASM JIT Pre-Warming ───
50
+ // After init, call each WASM function once with minimal config during idle time.
51
+ // This forces V8's JIT to compile the hot deserialization + rendering paths
52
+ // BEFORE the user's first interaction, eliminating the 20-50ms cold-call penalty.
53
+ //
54
+ // Uses requestIdleCallback (Chrome 47+, Firefox 55+) with setTimeout fallback.
55
+ // Each warm-up call is wrapped in try/catch — the results are discarded.
56
+
57
+ export function preWarmWasm(wasmFunctions) {
58
+ const run = () => {
59
+ for (const fn of wasmFunctions) {
60
+ try { fn({}); } catch { /* expected — missing required fields */ }
61
+ }
62
+ };
63
+ if (typeof requestIdleCallback === 'function') {
64
+ requestIdleCallback(run, { timeout: 200 });
65
+ } else {
66
+ setTimeout(run, 50);
67
+ }
68
+ }
69
+
70
+ // ─── Shared Stylesheets ───
71
+ // Created once, adopted by every Shadow Root. Zero duplication.
72
+ // tokens.css custom properties pierce Shadow DOM — loaded in <head> by consumer.
73
+ // cx-utilities.css (extracted Tailwind) shared via adoptedStyleSheets.
74
+
75
+ const sharedSheets = [];
76
+ const HAS_CONSTRUCTABLE_STYLESHEETS = typeof CSSStyleSheet !== 'undefined';
77
+ const HTMLElementBase = typeof HTMLElement === 'undefined' ? class {} : HTMLElement;
78
+ const _hostDisplaySheets = new Map();
79
+
80
+ function createSyncSheet(cssText) {
81
+ if (!HAS_CONSTRUCTABLE_STYLESHEETS) return null;
82
+ const sheet = new CSSStyleSheet();
83
+ sheet.replaceSync(cssText);
84
+ return sheet;
85
+ }
86
+
87
+ async function createAsyncSheet(cssText) {
88
+ if (!HAS_CONSTRUCTABLE_STYLESHEETS) return null;
89
+ const sheet = new CSSStyleSheet();
90
+ await sheet.replace(cssText);
91
+ return sheet;
92
+ }
93
+
94
+ function getExtraHostSheet(ctor) {
95
+ if (ctor._hostSheet) return ctor._hostSheet;
96
+ const display = ctor._hostDisplay || 'block';
97
+ let sheet = _hostDisplaySheets.get(display);
98
+ if (!sheet) {
99
+ sheet = createSyncSheet(`:host { display: ${display}; }`);
100
+ if (sheet) _hostDisplaySheets.set(display, sheet);
101
+ }
102
+ return sheet;
103
+ }
104
+
105
+ function getAdoptedSheets(ctor) {
106
+ const extra = getExtraHostSheet(ctor);
107
+ return extra ? [...sharedSheets, extra] : sharedSheets;
108
+ }
109
+
110
+ function adoptSheets(shadow, ctor) {
111
+ if (!shadow || !('adoptedStyleSheets' in shadow)) return;
112
+ const baseSheets = getAdoptedSheets(ctor);
113
+ const extraSheets = shadow.adoptedStyleSheets.filter(
114
+ sheet => !baseSheets.includes(sheet)
115
+ );
116
+ shadow.adoptedStyleSheets = [...baseSheets, ...extraSheets];
117
+ }
118
+
119
+ // Suppress default browser focus outline on host elements.
120
+ // When delegatesFocus is true, Chromium applies :focus-visible on BOTH
121
+ // the host AND the inner focusable element, causing a double focus ring.
122
+ // The inner element already has Tailwind focus-visible:ring-* classes
123
+ // from the Rust design system — only that ring should be visible.
124
+ // See: https://github.com/w3c/csswg-drafts/issues/5893
125
+ const hostSheet = createSyncSheet([
126
+ // Host display is now per-component (`static _hostDisplay`) so inline
127
+ // controls no longer inherit a shared `display: block`.
128
+ // overflow: visible ensures fixed-positioned floating content (tooltips,
129
+ // menus, popovers) is not clipped by the host element boundary.
130
+ ':host { overflow: visible; }',
131
+ // Inner buttons/links must fill the host width when the component chooses a
132
+ // block layout. Without this, a host with width:100% leaves dead space around
133
+ // the inner control that swallows click events without shadow DOM delegation.
134
+ //
135
+ // CRITICAL: This MUST be inside @layer base so that explicit Tailwind sizing
136
+ // utilities (size-10, size-14 etc. in @layer utilities) can override it.
137
+ // Without @layer, this rule beats ALL layered rules (CSS cascade: unlayered
138
+ // wins over layered regardless of specificity), causing icon-only buttons
139
+ // and FABs to collapse to content width instead of their intended square size.
140
+ '@layer base {',
141
+ ' :host > button, :host > a { width: 100%; min-width: max-content; box-sizing: border-box; }',
142
+ '}',
143
+ // Suppress Chromium's double focus ring (inner element has its own Tailwind ring).
144
+ ':host(:focus) { outline: none; }',
145
+ ':host(:focus-visible) { outline: none; }',
146
+ // Interaction lockout during animations — set data-cx-busy on the host
147
+ // to prevent clicks while an open/close/toggle animation is in progress.
148
+ ':host([data-cx-busy]) { pointer-events: none !important; }',
149
+ // Tailwind v4 uses @property (inherits:false) to initialize internal CSS vars.
150
+ // @property registrations don't propagate into Shadow DOM — the variables stay
151
+ // undefined, breaking box-shadow/ring/transform computations.
152
+ //
153
+ // CRITICAL: This MUST be in @layer properties (Tailwind's lowest-priority layer).
154
+ // Unlayered styles beat ALL layered styles in the CSS cascade — if this were
155
+ // unlayered, Tailwind's @layer utilities rules (focus-visible:ring-2 etc.)
156
+ // could never override these defaults, and focus rings would stay invisible.
157
+ '@layer properties {',
158
+ ' *, ::before, ::after {',
159
+ ' --tw-shadow: 0 0 #0000;',
160
+ ' --tw-shadow-color: initial;',
161
+ ' --tw-shadow-alpha: 100%;',
162
+ ' --tw-inset-shadow: 0 0 #0000;',
163
+ ' --tw-inset-shadow-color: initial;',
164
+ ' --tw-inset-shadow-alpha: 100%;',
165
+ ' --tw-ring-color: initial;',
166
+ ' --tw-ring-shadow: 0 0 #0000;',
167
+ ' --tw-inset-ring-color: initial;',
168
+ ' --tw-inset-ring-shadow: 0 0 #0000;',
169
+ ' --tw-ring-inset: initial;',
170
+ ' --tw-ring-offset-width: 0px;',
171
+ ' --tw-ring-offset-color: #fff;',
172
+ ' --tw-ring-offset-shadow: 0 0 #0000;',
173
+ ' --tw-translate-x: 0;',
174
+ ' --tw-translate-y: 0;',
175
+ ' --tw-border-style: solid;',
176
+ ' --tw-outline-style: solid;',
177
+ ' }',
178
+ '}',
179
+ ].join('\n'));
180
+ if (hostSheet) sharedSheets.push(hostSheet);
181
+
182
+ // Essential loading animations override.
183
+ // tokens.css kills ALL keyframe animations under prefers-reduced-motion:
184
+ // *, *::before, *::after { animation-duration: 0.01ms !important; }
185
+ // This is correct for decorative motion, but spinners/skeletons/progress bars
186
+ // are essential loading feedback — WCAG 2.2 allows essential animations even
187
+ // when users prefer reduced motion. These overrides restore them.
188
+ // Uses class selectors (specificity 0,1,0) to beat * (specificity 0,0,0).
189
+ const loadingSheet = createSyncSheet([
190
+ '@media (prefers-reduced-motion: reduce) {',
191
+ ' .animate-spin { animation: spin 1s linear infinite !important; }',
192
+ ' .animate-spin-square { animation: spin-square 1.8s ease-in-out infinite !important; }',
193
+ ' .animate-pulse { animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite !important; }',
194
+ ' .animate-skeleton-wave::after { animation: skeleton-wave 2s linear infinite !important; }',
195
+ ' .animate-progress-indeterminate { animation: progress-indeterminate 1.5s ease-in-out infinite !important; }',
196
+ '}',
197
+ ].join('\n'));
198
+ if (loadingSheet) sharedSheets.push(loadingSheet);
199
+
200
+ // Tailwind v4 compatibility + interactive transition overrides.
201
+ //
202
+ // 1. Tailwind v4 uses CSS `translate` / `rotate` / `scale` properties
203
+ // (NOT `transform`). tokens.css transitions target `transform`, which
204
+ // won't animate Tailwind v4's individual transform properties.
205
+ // This sheet fixes the mismatch for affected components.
206
+ //
207
+ // 2. Under prefers-reduced-motion, tokens.css sets --duration-* vars to
208
+ // 0.01ms on :root (inherited into Shadow DOM), killing all CSS
209
+ // transitions. Interactive transitions (switch slide, accordion
210
+ // chevron, collapsible chevron) provide state-change feedback and
211
+ // should retain brief, simple motion. We restore them with reduced
212
+ // durations and linear easing (no overshoot).
213
+ const compatSheet = createSyncSheet([
214
+ // Switch thumb: transition `translate` (Tailwind v4 property).
215
+ // !important overrides tokens.css `transition: transform` which loads later.
216
+ 'button[role="switch"] > span[aria-hidden="true"] {',
217
+ ' transition: translate var(--duration-smooth, 300ms) var(--ease-spring, cubic-bezier(0.175, 0.885, 0.32, 1.275)) !important;',
218
+ '}',
219
+ '',
220
+ // Checkbox: preserve checked indicator color on hover.
221
+ // MicroInteraction::color_shift() adds hover:text-[var(--color-primary)]
222
+ // which overrides peer-checked:text-[var(--color-text-inverse)] in Tailwind
223
+ // cascade order. Result: blue checkmark on blue background = invisible.
224
+ // This rule has higher specificity (0,3,1) than the Tailwind hover utility
225
+ // (0,2,0), so it correctly preserves the white checkmark on hover.
226
+ 'input.peer:checked + [aria-hidden="true"]:hover { color: var(--color-text-inverse); }',
227
+ '',
228
+ // Tailwind v4 cascade order: .inline-flex overrides .hidden when both classes
229
+ // are on the same element (inline-flex appears later in the CSS). This breaks
230
+ // listbox check indicators which use "inline-flex ... hidden" to toggle visibility.
231
+ // Scoped to [data-check] so it doesn't break "hidden data-[open]:block" on dropdowns.
232
+ '[data-check].hidden { display: none !important; }',
233
+ '',
234
+ // Ensure border-box in shadow DOM (Tailwind preflight may not load first)
235
+ '*, *::before, *::after { box-sizing: border-box; }',
236
+ '',
237
+ // Reduced-motion: restore brief interactive transitions
238
+ '@media (prefers-reduced-motion: reduce) {',
239
+ ' :host {',
240
+ ' --duration-fast: 100ms;',
241
+ ' --duration-normal: 150ms;',
242
+ ' --duration-smooth: 200ms;',
243
+ ' --ease-spring: ease-out;',
244
+ ' }',
245
+ '}',
246
+ ].join('\n'));
247
+ if (compatSheet) sharedSheets.push(compatSheet);
248
+
249
+ export async function loadSharedStyles(urls) {
250
+ const results = await Promise.all(
251
+ urls.map(url => fetch(url).then(r => r.text()))
252
+ );
253
+ for (const cssText of results) {
254
+ const sheet = await createAsyncSheet(cssText);
255
+ if (sheet) sharedSheets.push(sheet);
256
+ }
257
+ return sharedSheets;
258
+ }
259
+
260
+ // Load utility CSS from an inline string (no fetch). Used by zero-config init.
261
+ export async function loadInlineSharedStyles(cssText) {
262
+ const sheet = await createAsyncSheet(cssText);
263
+ if (sheet) sharedSheets.push(sheet);
264
+ }
265
+
266
+ // ─── Shadow DOM Token Loading ───
267
+ // The build produces two separate CSS outputs:
268
+ // tokens.css — full design tokens (vars, fonts, themes) for document <head>
269
+ // tokens-shadow.css — Shadow DOM subset (animations, motion, component rules)
270
+ //
271
+ // CSS custom properties (colors, spacing, easing) defined in <head> inherit into
272
+ // Shadow DOM automatically. The shadow subset contains only rules that Shadow DOM
273
+ // elements need but can't inherit: @keyframes, component motion, slider/scrollbar
274
+ // pseudo-elements, etc.
275
+ //
276
+ // This build-time split eliminates the previous runtime CSS parser
277
+ // (stripTokensForShadowDom) which broke across three patch versions due to
278
+ // comment handling, nested @media blocks, and regex over-matching.
279
+
280
+ // Load shadow token CSS from a URL (fetch). Used when explicit CSS URLs are provided.
281
+ export async function loadMotionStyles(url) {
282
+ // When given the full tokens URL, fetch and strip at runtime (legacy path).
283
+ // Prefer passing the tokens-shadow.css URL directly via tokensUrl.
284
+ const raw = await fetch(url).then(r => r.text());
285
+ const sheet = await createAsyncSheet(raw);
286
+ if (sheet) sharedSheets.push(sheet);
287
+ }
288
+
289
+ // Load shadow token CSS from an inline string (no fetch). Used by zero-config init.
290
+ export async function loadInlineMotionStyles(raw) {
291
+ const sheet = await createAsyncSheet(raw);
292
+ if (sheet) sharedSheets.push(sheet);
293
+ }
294
+
295
+ // Inject tokens.css into document <head> as a <style> element (no <link> needed).
296
+ // PREPENDED (not appended) so consumer CSS that loads later naturally wins
297
+ // the cascade — same :root selector, same specificity, later source wins.
298
+ // This means `--color-primary: red` in the consumer's index.css overrides
299
+ // the library default with zero !important hacks.
300
+ export function injectTokensToHead(cssText) {
301
+ if (document.getElementById('cx-tokens')) return;
302
+ const style = document.createElement('style');
303
+ style.id = 'cx-tokens';
304
+ style.textContent = cssText;
305
+ document.head.prepend(style);
306
+ }
307
+
308
+ // ─── FOUC Prevention ───
309
+ // Hide cx-* elements until their Custom Element class is defined.
310
+ // Uses :not(:defined) — a native CSS pseudo-class that matches elements
311
+ // whose constructor hasn't been registered via customElements.define().
312
+ // Once defined, the element becomes :defined and immediately appears.
313
+ // Generated from the component registry — always in sync, zero maintenance.
314
+ export function injectFoucPrevention(componentNames) {
315
+ if (document.getElementById('cx-fouc')) return;
316
+ const selectors = componentNames.map(n => `cx-${n}:not(:defined)`);
317
+ const style = document.createElement('style');
318
+ style.id = 'cx-fouc';
319
+ // display:none prevents the inline-display footgun (Custom Elements default
320
+ // to display:inline before their shadow DOM styles load). Once the element
321
+ // class is registered via customElements.define(), :not(:defined) stops
322
+ // matching and the component's _hostDisplay takes over.
323
+ style.textContent = selectors.join(',') + '{display:none}';
324
+ document.head.prepend(style);
325
+ }
326
+
327
+ export function getSharedSheets() {
328
+ return sharedSheets;
329
+ }
330
+
331
+ // ─── Base Custom Element ───
332
+ // Every Collet element follows this pattern:
333
+ // 1. Attach Shadow DOM (open mode) in constructor
334
+ // 2. Adopt shared stylesheets
335
+ // 3. Collect attribute changes into a props object
336
+ // 4. Batch renders via queueMicrotask (coalesces multiple changes)
337
+ // 5. Call WASM render function with props → inject HTML into shadow root
338
+
339
+ // ─── Scroll Lock ───
340
+ // Prevents background scrolling when modals/drawers are open.
341
+ // Compensates for scrollbar removal with padding-right to prevent layout shift.
342
+ // Reference-counted: multiple concurrent modals share one lock.
343
+
344
+ let _scrollLockCount = 0;
345
+
346
+ export class CxElement extends HTMLElementBase {
347
+ #shadow;
348
+ #props = {};
349
+ #pendingRender = false;
350
+ #prevAria = {};
351
+ #initialized = false;
352
+
353
+ constructor() {
354
+ super();
355
+ // Declarative Shadow DOM: if a <template shadowrootmode="open"> was in the
356
+ // HTML, the browser already created this.shadowRoot before any JS ran.
357
+ // Reuse it — calling attachShadow() on an element that already has a shadow
358
+ // root throws a DOMException. This is the "resumability" mechanism: the
359
+ // server/build pre-renders HTML, the browser paints it instantly, and when
360
+ // this constructor runs the element upgrades without re-creating the DOM.
361
+ if (this.shadowRoot) {
362
+ this.#shadow = this.shadowRoot;
363
+ } else if (typeof this.attachShadow === 'function') {
364
+ this.#shadow = this.attachShadow({ mode: 'open' });
365
+ }
366
+ // Subclasses can define `static _hostDisplay = 'inline-flex'` or provide
367
+ // a custom `static _hostSheet` for specialized host styles.
368
+ if (this.#shadow) adoptSheets(this.#shadow, this.constructor);
369
+ }
370
+
371
+ // Subclasses access these for rendering
372
+ get _shadow() { return this.#shadow; }
373
+ get _props() { return this.#props; }
374
+ // Guard against duplicate listener attachment on DOM reparenting.
375
+ // connectedCallback fires every time the element is moved in the DOM —
376
+ // shadow event listeners must only be added once.
377
+ get _isInitialized() { return this.#initialized; }
378
+ _markInitialized() { this.#initialized = true; }
379
+
380
+ // ─── Lifecycle ───
381
+
382
+ connectedCallback() {
383
+ if (!this.#shadow) return;
384
+ // Auto-generate a unique ID if the consumer didn't provide one.
385
+ // Many Rust component builders require an ID for ARIA associations
386
+ // (aria-labelledby, aria-controls, etc.). Without this, serde
387
+ // deserialization would fail or produce empty HTML.
388
+ if (!this.#props.id) {
389
+ const tag = this.tagName.toLowerCase().replace('cx-', '');
390
+ this.#props.id = `cx-${tag}-${Math.random().toString(36).slice(2, 8)}`;
391
+ }
392
+
393
+ // Re-adopt styles — sharedSheets grows as init() loads tokens.css and
394
+ // utilities CSS. The constructor may run before those are ready, leaving
395
+ // the shadow root with only the base host display sheet. Re-adoption is
396
+ // cheap (no re-parse) and ensures every render has all styles available.
397
+ adoptSheets(this.#shadow, this.constructor);
398
+ this._scheduleRender();
399
+ }
400
+
401
+ disconnectedCallback() {
402
+ // Remove from pending queue to prevent memory leaks.
403
+ // Elements removed from DOM before WASM loads would otherwise
404
+ // stay in the Set forever, holding strong references.
405
+ _pendingElements.delete(this);
406
+ }
407
+
408
+ attributeChangedCallback(name, _old, value) {
409
+ // Guard: stripping host title attribute to suppress native browser tooltip.
410
+ // Only fires for components with 'title' in observedAttributes (dialog,
411
+ // drawer, popover, alert, toast) — they use title as content, not tooltip.
412
+ if (name === 'title' && this.__strippingTitle) return;
413
+
414
+ // Convert kebab-case attribute names to snake_case for Rust serde.
415
+ // HTML: icon-leading → Rust: icon_leading
416
+ const key = name.replace(/-/g, '_');
417
+ const isBooleanAttr = this.constructor._booleanAttrs?.has(name);
418
+ const isNumericAttr = this.constructor._numericAttrs?.has(name);
419
+
420
+ let newValue;
421
+ if (value === null) {
422
+ // Attribute removed → delete key so serde's #[serde(default)] supplies
423
+ // the correct default. Works for both default-false (disabled) and
424
+ // default-true (show_icon) booleans.
425
+ newValue = undefined;
426
+ } else if (isBooleanAttr && value === 'false') {
427
+ // Explicit "false" string → boolean false (overrides default-true fields)
428
+ newValue = false;
429
+ } else if (isBooleanAttr && (value === '' || value === 'true')) {
430
+ // Boolean attribute present with no value → true.
431
+ // ONLY for fields declared as boolean in the Rust config struct.
432
+ // Without this guard, string fields like `value=""` or `label=""`
433
+ // would be coerced to boolean true, causing serde deserialization
434
+ // errors ("invalid type: boolean, expected a string").
435
+ newValue = true;
436
+ } else if (isNumericAttr && value !== '') {
437
+ // Numeric attributes (value, min, max, step, page, etc.) must be
438
+ // passed as JS numbers for serde_wasm_bindgen to deserialize into
439
+ // Rust f64/usize. HTML attributes are always strings — without this
440
+ // coercion, serde fails with "invalid type: string, expected f64".
441
+ const num = Number(value);
442
+ newValue = isNaN(num) ? value : num;
443
+ } else if (value === '') {
444
+ // Empty string for non-boolean fields — pass as empty string.
445
+ newValue = value;
446
+ } else if (value.length > 1 && (value[0] === '[' || value[0] === '{')) {
447
+ // JSON array/object string → parsed JS value for serde_wasm_bindgen.
448
+ // WASM expects actual arrays/objects (Vec<T>, HashMap), not strings.
449
+ try {
450
+ newValue = JSON.parse(value);
451
+ } catch {
452
+ newValue = value; // not valid JSON — keep as string
453
+ }
454
+ } else {
455
+ // HTML attributes are strings. Keep primitive values as strings so
456
+ // Rust serde receives `value: "2021"` instead of `value: 2021`.
457
+ newValue = value;
458
+ }
459
+
460
+ // Skip re-render when the resolved value matches what's already in props.
461
+ // This prevents interactive elements (checkbox, switch) from having their
462
+ // CSS transitions killed when React echoes back the same state that the
463
+ // internal change/click handler already set.
464
+ if (newValue === undefined) {
465
+ if (!(key in this.#props)) return;
466
+ delete this.#props[key];
467
+ } else {
468
+ if (this.#props[key] === newValue) return;
469
+ this.#props[key] = newValue;
470
+ }
471
+ if (this.isConnected) this._scheduleRender();
472
+
473
+ // Strip 'title' from host element to suppress native browser tooltip.
474
+ // The value is already stored in _props for WASM rendering.
475
+ if (name === 'title' && value !== null) {
476
+ this.__strippingTitle = true;
477
+ this.removeAttribute('title');
478
+ this.__strippingTitle = false;
479
+ }
480
+ }
481
+
482
+ // ─── Batched Rendering ───
483
+
484
+ _scheduleRender() {
485
+ if (!this.#shadow) return;
486
+ if (this.#pendingRender) return;
487
+ this.#pendingRender = true;
488
+ queueMicrotask(() => {
489
+ this.#pendingRender = false;
490
+ // Guard: element may have been disconnected between scheduling and execution.
491
+ if (!this.isConnected) return;
492
+ // Ensure latest shared styles are adopted before rendering.
493
+ // Preserve any component-specific sheets already attached by subclasses.
494
+ adoptSheets(this.#shadow, this.constructor);
495
+ // If WASM isn't loaded yet (lazy init), queue for re-render when ready.
496
+ // initWasm() flushes _pendingElements on load.
497
+ if (!wasmExports) {
498
+ _pendingElements.add(this);
499
+ // Pre-render fallback: inject <slot> so light DOM children are visible
500
+ // while WASM loads. This enables progressive enhancement — consumers can
501
+ // put fallback content inside Custom Elements. For slot-based components
502
+ // (scrollbar, collapsible, card), their content is visible immediately.
503
+ if (!this.#shadow.firstChild) {
504
+ this.#shadow.innerHTML = '<slot></slot>';
505
+ }
506
+ return;
507
+ }
508
+ this._doRender();
509
+ });
510
+ }
511
+
512
+ // Override in subclass to call the WASM render function
513
+ _doRender() {}
514
+
515
+ // Inject WASM render result into Shadow Root and apply a11y to host.
516
+ //
517
+ // The a11y metadata originates from Rust's compile-time Accessible trait:
518
+ // Rust Behavior::aria_attrs() → A11yMetadata → host element attributes
519
+ //
520
+ // This propagates the three-layer accessibility guarantee through
521
+ // the Shadow DOM boundary — the host element reflects the inner
522
+ // component's ARIA state for external CSS selectors and AT.
523
+ _injectHtml(result) {
524
+ // Preserve focus across innerHTML replacement. Without this, typing
525
+ // in an <input> inside shadow DOM loses focus on every re-render
526
+ // because innerHTML destroys and recreates all DOM nodes.
527
+ const active = this.#shadow.activeElement;
528
+ let focusInfo = null;
529
+ if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) {
530
+ focusInfo = {
531
+ tag: active.tagName,
532
+ type: active.type || '',
533
+ value: active.value,
534
+ selStart: active.selectionStart,
535
+ selEnd: active.selectionEnd,
536
+ };
537
+ }
538
+
539
+ this.#shadow.innerHTML = result.sprites + result.html;
540
+
541
+ // Restore focus to equivalent element after DOM replacement
542
+ if (focusInfo) {
543
+ const selector = focusInfo.type
544
+ ? `${focusInfo.tag}[type="${focusInfo.type}"]`
545
+ : focusInfo.tag;
546
+ const target = this.#shadow.querySelector(selector) || this.#shadow.querySelector(focusInfo.tag);
547
+ if (target) {
548
+ target.value = focusInfo.value;
549
+ target.focus();
550
+ try { target.setSelectionRange(focusInfo.selStart, focusInfo.selEnd); } catch { /* not all inputs support selection */ }
551
+ }
552
+ }
553
+
554
+ if (result.a11y) {
555
+ // Apply role to host (only when inner HTML doesn't imply it)
556
+ if (result.a11y.role) {
557
+ this.setAttribute('role', result.a11y.role);
558
+ }
559
+
560
+ // Reflect ARIA attributes on host element.
561
+ // Remove stale attrs from previous render, add/update current ones.
562
+ const newAria = result.a11y.aria || {};
563
+
564
+ // Remove attrs that were set last render but aren't in this render
565
+ for (const key of Object.keys(this.#prevAria)) {
566
+ if (!(key in newAria)) {
567
+ this.removeAttribute(key);
568
+ }
569
+ }
570
+ // Set current ARIA attrs
571
+ for (const [key, value] of Object.entries(newAria)) {
572
+ this.setAttribute(key, value);
573
+ }
574
+ this.#prevAria = newAria;
575
+ }
576
+ }
577
+
578
+ // ─── Interaction Guards ───
579
+
580
+ // Named action lock — prevents re-entry during an in-progress action.
581
+ // The lock holds until:
582
+ // - Web Animation: after .finished resolves
583
+ // - Promise: after resolution
584
+ // - Sync: after next animation frame
585
+ // Use for open/close, toggle, and any stateful animation flow.
586
+ _guard(key, fn) {
587
+ const guards = this.__guards ??= new Set();
588
+ if (guards.has(key)) return undefined;
589
+ guards.add(key);
590
+ const release = () => guards.delete(key);
591
+ try {
592
+ const result = fn();
593
+ if (result?.finished) {
594
+ // Web Animation — release after it completes
595
+ result.finished.then(release, release);
596
+ } else if (result?.then) {
597
+ // Promise — release on settle
598
+ result.then(release, release);
599
+ } else {
600
+ // Sync — release next frame (prevents same-frame re-entry)
601
+ requestAnimationFrame(release);
602
+ }
603
+ return result;
604
+ } catch (e) {
605
+ release();
606
+ throw e;
607
+ }
608
+ }
609
+
610
+ // Check if a named guard is currently active.
611
+ _isGuarded(key) {
612
+ return this.__guards?.has(key) ?? false;
613
+ }
614
+
615
+ // ─── Event Helpers ───
616
+
617
+ // Action events that get automatic interaction cooldown.
618
+ // Prevents the same component from emitting the same action event
619
+ // twice within 150ms — catches accidental double-clicks, rapid toggles,
620
+ // and animation-overlap scenarios. Input/focus/keyboard events are NOT
621
+ // included — they fire freely for responsive typing and navigation.
622
+ static _actionEvents = new Set([
623
+ 'cx-click', 'cx-close', 'cx-sort', 'cx-select',
624
+ 'cx-action', 'cx-change', 'cx-dismiss', 'cx-navigate',
625
+ ]);
626
+
627
+ // Dispatch a Custom Event that bubbles out of Shadow DOM.
628
+ // Frameworks can listen via element.addEventListener or JSX handlers.
629
+ // Action events include automatic 150ms cooldown per event type.
630
+ _emit(name, detail) {
631
+ if (CxElement._actionEvents.has(name)) {
632
+ const cdKey = `__cxCd_${name}`;
633
+ if (this[cdKey]) return;
634
+ this[cdKey] = true;
635
+ setTimeout(() => { this[cdKey] = false; }, 150);
636
+ }
637
+ this.dispatchEvent(new CustomEvent(name, {
638
+ detail,
639
+ bubbles: true,
640
+ composed: true, // crosses Shadow DOM boundary
641
+ }));
642
+ }
643
+
644
+ // ─── Property Setters ───
645
+ // For non-string values that can't be set via HTML attributes.
646
+
647
+ _setProp(key, value) {
648
+ this.#props[key] = value;
649
+ if (this.isConnected) this._scheduleRender();
650
+ }
651
+
652
+ // ─── Scroll Lock ───
653
+ // Used by dialog/drawer to prevent background scrolling.
654
+ // Adds padding-right to <html> equal to scrollbar width so removing
655
+ // the scrollbar doesn't shift page content (the Radix UI approach).
656
+ // Reference-counted for nested modals.
657
+
658
+ _lockScroll() {
659
+ if (_scrollLockCount === 0) {
660
+ const scrollbarW = window.innerWidth - document.documentElement.clientWidth;
661
+ document.documentElement.style.overflow = 'hidden';
662
+ if (scrollbarW > 0) {
663
+ document.documentElement.style.paddingRight = `${scrollbarW}px`;
664
+ }
665
+ }
666
+ _scrollLockCount++;
667
+ }
668
+
669
+ _unlockScroll() {
670
+ _scrollLockCount = Math.max(0, _scrollLockCount - 1);
671
+ if (_scrollLockCount === 0) {
672
+ document.documentElement.style.overflow = '';
673
+ document.documentElement.style.paddingRight = '';
674
+ }
675
+ }
676
+
677
+ // ─── Fixed-Position Floating ───
678
+ // Positions a floating panel with `position: fixed` using viewport coordinates.
679
+ // This escapes `overflow: auto/scroll/hidden` on ancestor elements (e.g. when
680
+ // a Popover/Select/Menu is inside <cx-scrollbar>). Absolute positioning clips;
681
+ // fixed positioning is relative to the viewport.
682
+ //
683
+ // opts.matchWidth — set panel width to trigger width (for Select/Autocomplete)
684
+ // opts.gap — pixel gap between trigger and panel (default: 4)
685
+ // Returns 'top' or 'bottom' (the chosen placement).
686
+
687
+ _positionFloatingFixed(trigger, panel, opts) {
688
+ const rect = trigger.getBoundingClientRect();
689
+ const gap = opts?.gap ?? 4;
690
+ const vh = window.innerHeight;
691
+ const below = vh - rect.bottom - gap;
692
+ const above = rect.top - gap;
693
+ const openAbove = below < 120 && above > below;
694
+
695
+ panel.style.position = 'fixed';
696
+ panel.style.zIndex = '50';
697
+ panel.style.left = rect.left + 'px';
698
+ if (opts?.matchWidth) panel.style.width = rect.width + 'px';
699
+ else panel.style.minWidth = rect.width + 'px';
700
+
701
+ if (openAbove) {
702
+ panel.style.top = '';
703
+ panel.style.bottom = (vh - rect.top + gap) + 'px';
704
+ } else {
705
+ panel.style.top = (rect.bottom + gap) + 'px';
706
+ panel.style.bottom = '';
707
+ }
708
+
709
+ panel.setAttribute('data-placement', openAbove ? 'top' : 'bottom');
710
+ return openAbove ? 'top' : 'bottom';
711
+ }
712
+
713
+ _resetFloatingFixed(panel) {
714
+ if (!panel) return;
715
+ panel.style.position = '';
716
+ panel.style.zIndex = '';
717
+ panel.style.left = '';
718
+ panel.style.right = '';
719
+ panel.style.top = '';
720
+ panel.style.bottom = '';
721
+ panel.style.width = '';
722
+ panel.style.minWidth = '';
723
+ panel.removeAttribute('data-placement');
724
+ }
725
+ }
726
+
727
+ // ─── Form-Associated Base ───
728
+ // Elements that participate in <form> submissions extend this.
729
+ // Uses ElementInternals API (Chrome 77+, Firefox 98+, Safari 16.4+).
730
+
731
+ export class CxFormElement extends CxElement {
732
+ static formAssociated = true;
733
+ #internals;
734
+
735
+ constructor() {
736
+ super();
737
+ this.#internals = typeof this.attachInternals === 'function'
738
+ ? this.attachInternals()
739
+ : null;
740
+ }
741
+
742
+ get _internals() { return this.#internals; }
743
+
744
+ // Set the form submission value. Called after each render.
745
+ _setFormValue(value) {
746
+ this.#internals?.setFormValue(value);
747
+ }
748
+
749
+ // Set validation state.
750
+ _setValidity(flags, message, anchor) {
751
+ if (flags) {
752
+ this.#internals?.setValidity(flags, message, anchor);
753
+ } else {
754
+ this.#internals?.setValidity({});
755
+ }
756
+ }
757
+
758
+ // Native form lifecycle callbacks
759
+ formResetCallback() {
760
+ this._setProp('value', '');
761
+ this._setFormValue('');
762
+ }
763
+
764
+ formDisabledCallback(disabled) {
765
+ this._setProp('disabled', disabled);
766
+ }
767
+
768
+ // Expose internals for label association.
769
+ // Getters AND setters for properties that React sets directly on the DOM
770
+ // element (element.name = 'foo', element.value = 'bar'). Without setters,
771
+ // React crashes: "Cannot set property X of #<CxFormElement> which has only
772
+ // a getter". The setter delegates to setAttribute so the normal
773
+ // attributeChangedCallback → props → render pipeline runs.
774
+ get form() { return this.#internals?.form ?? null; }
775
+ get name() { return this.getAttribute?.('name') ?? null; }
776
+ set name(v) { if (v == null) this.removeAttribute('name'); else this.setAttribute('name', String(v)); }
777
+ get type() { return this.getAttribute?.('type') ?? this.localName; }
778
+ set type(v) { if (v != null) this.setAttribute('type', String(v)); }
779
+ get value() { return this.getAttribute?.('value') ?? ''; }
780
+ set value(v) { if (v == null) this.removeAttribute('value'); else this.setAttribute('value', String(v)); }
781
+ get disabled() { return this.hasAttribute('disabled'); }
782
+ set disabled(v) { if (v) this.setAttribute('disabled', ''); else this.removeAttribute('disabled'); }
783
+ get required() { return this.hasAttribute('required'); }
784
+ set required(v) { if (v) this.setAttribute('required', ''); else this.removeAttribute('required'); }
785
+ get validity() { return this.#internals?.validity ?? null; }
786
+ get validationMessage() { return this.#internals?.validationMessage ?? ''; }
787
+ get willValidate() { return this.#internals?.willValidate ?? false; }
788
+ checkValidity() { return this.#internals?.checkValidity?.() ?? true; }
789
+ reportValidity() { return this.#internals?.reportValidity?.() ?? true; }
790
+ }