@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.
- package/README.md +77 -0
- package/custom-elements.json +6037 -0
- package/generated/.gitattributes +2 -0
- package/generated/index.d.ts +120 -0
- package/generated/index.js +521 -0
- package/generated/styles.js +2845 -0
- package/package.json +56 -0
- package/src/elements/accordion.d.ts +20 -0
- package/src/elements/accordion.js +92 -0
- package/src/elements/activity_group.d.ts +19 -0
- package/src/elements/activity_group.js +27 -0
- package/src/elements/alert.d.ts +24 -0
- package/src/elements/alert.js +40 -0
- package/src/elements/autocomplete.d.ts +30 -0
- package/src/elements/autocomplete.js +671 -0
- package/src/elements/avatar.d.ts +18 -0
- package/src/elements/avatar.js +28 -0
- package/src/elements/backdrop.d.ts +14 -0
- package/src/elements/backdrop.js +28 -0
- package/src/elements/badge.d.ts +21 -0
- package/src/elements/badge.js +42 -0
- package/src/elements/breadcrumb.d.ts +17 -0
- package/src/elements/breadcrumb.js +41 -0
- package/src/elements/button.d.ts +24 -0
- package/src/elements/button.js +36 -0
- package/src/elements/card.d.ts +21 -0
- package/src/elements/card.js +67 -0
- package/src/elements/carousel.d.ts +23 -0
- package/src/elements/carousel.js +895 -0
- package/src/elements/chat_input.d.ts +22 -0
- package/src/elements/chat_input.js +78 -0
- package/src/elements/checkbox.d.ts +21 -0
- package/src/elements/checkbox.js +114 -0
- package/src/elements/code_block.d.ts +21 -0
- package/src/elements/code_block.js +27 -0
- package/src/elements/collapsible.d.ts +20 -0
- package/src/elements/collapsible.js +93 -0
- package/src/elements/date_picker.d.ts +30 -0
- package/src/elements/date_picker.js +528 -0
- package/src/elements/dialog.d.ts +20 -0
- package/src/elements/dialog.js +314 -0
- package/src/elements/drawer.d.ts +20 -0
- package/src/elements/drawer.js +318 -0
- package/src/elements/fab.d.ts +22 -0
- package/src/elements/fab.js +36 -0
- package/src/elements/file_upload.d.ts +26 -0
- package/src/elements/file_upload.js +59 -0
- package/src/elements/listbox.d.ts +19 -0
- package/src/elements/listbox.js +250 -0
- package/src/elements/menu.d.ts +20 -0
- package/src/elements/menu.js +224 -0
- package/src/elements/message_bubble.d.ts +23 -0
- package/src/elements/message_bubble.js +29 -0
- package/src/elements/message_group.d.ts +18 -0
- package/src/elements/message_group.js +28 -0
- package/src/elements/message_part.d.ts +35 -0
- package/src/elements/message_part.js +153 -0
- package/src/elements/pagination.d.ts +22 -0
- package/src/elements/pagination.js +36 -0
- package/src/elements/popover.d.ts +26 -0
- package/src/elements/popover.js +191 -0
- package/src/elements/profile_menu.d.ts +20 -0
- package/src/elements/profile_menu.js +213 -0
- package/src/elements/progress.d.ts +18 -0
- package/src/elements/progress.js +31 -0
- package/src/elements/radio_group.d.ts +22 -0
- package/src/elements/radio_group.js +70 -0
- package/src/elements/scrollbar.d.ts +19 -0
- package/src/elements/scrollbar.js +299 -0
- package/src/elements/search_bar.d.ts +27 -0
- package/src/elements/search_bar.js +98 -0
- package/src/elements/select.d.ts +26 -0
- package/src/elements/select.js +485 -0
- package/src/elements/sidebar.d.ts +21 -0
- package/src/elements/sidebar.js +322 -0
- package/src/elements/skeleton.d.ts +17 -0
- package/src/elements/skeleton.js +31 -0
- package/src/elements/slider.d.ts +28 -0
- package/src/elements/slider.js +93 -0
- package/src/elements/speed_dial.d.ts +23 -0
- package/src/elements/speed_dial.js +370 -0
- package/src/elements/spinner.d.ts +15 -0
- package/src/elements/spinner.js +28 -0
- package/src/elements/split_button.d.ts +23 -0
- package/src/elements/split_button.js +281 -0
- package/src/elements/stepper.d.ts +20 -0
- package/src/elements/stepper.js +31 -0
- package/src/elements/switch.d.ts +22 -0
- package/src/elements/switch.js +129 -0
- package/src/elements/table.d.ts +29 -0
- package/src/elements/table.js +371 -0
- package/src/elements/tabs.d.ts +19 -0
- package/src/elements/tabs.js +139 -0
- package/src/elements/text.d.ts +26 -0
- package/src/elements/text.js +32 -0
- package/src/elements/text_input.d.ts +36 -0
- package/src/elements/text_input.js +121 -0
- package/src/elements/thinking.d.ts +17 -0
- package/src/elements/thinking.js +28 -0
- package/src/elements/toast.d.ts +23 -0
- package/src/elements/toast.js +209 -0
- package/src/elements/toggle_group.d.ts +22 -0
- package/src/elements/toggle_group.js +176 -0
- package/src/elements/tooltip.d.ts +18 -0
- package/src/elements/tooltip.js +64 -0
- package/src/markdown.d.ts +24 -0
- package/src/markdown.js +66 -0
- package/src/runtime.d.ts +35 -0
- package/src/runtime.js +790 -0
- package/src/server.d.ts +69 -0
- package/src/server.js +176 -0
- package/src/streaming-markdown.js +43 -0
- package/src/vite-plugin.d.ts +46 -0
- package/src/vite-plugin.js +221 -0
- package/wasm/package.json +16 -0
- package/wasm/wasm_api.d.ts +72 -0
- package/wasm/wasm_api.js +593 -0
- package/wasm/wasm_api_bg.wasm +0 -0
- 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
|
+
}
|