@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
@@ -0,0 +1,213 @@
1
+ // Custom behavior for <cx-profile-menu> — avatar trigger, dropdown menu, keyboard nav.
2
+ //
3
+ // The Rust component renders:
4
+ // <div data-profile-menu> — container
5
+ // <button data-floating-trigger aria-haspopup="menu" aria-expanded="false"> — avatar trigger
6
+ // <div role="menu" data-menu data-floating> — dropdown panel
7
+ // <button role="menuitem" data-item-id="{id}"> — menu items
8
+ // <button role="menuitemcheckbox" data-item-id="{id}" aria-checked> — checkbox items
9
+ // <button role="menuitemradio" data-item-id="{id}" aria-checked> — radio items
10
+ // <div role="separator"> — dividers
11
+ //
12
+ // This Custom Element wires up:
13
+ // - Click avatar to toggle menu open/close
14
+ // - Click menu item → cx-action event + close
15
+ // - Click checkbox/radio → toggle aria-checked + cx-change event (no close)
16
+ // - Click outside / focus exit → close
17
+ // - Keyboard: ArrowDown/Up nav, Home/End, Enter/Space activate, Escape closes
18
+ //
19
+ // Source: crates/wasm-api/src/profile_menu.rs
20
+
21
+ export function defineCxProfileMenu(wasmFn, baseClass) {
22
+ class CxProfileMenu extends baseClass {
23
+ static observedAttributes = ['id', 'label', 'image', 'initials', 'shape', 'size', 'width', 'entries', 'disabled'];
24
+ static _booleanAttrs = new Set(['disabled']);
25
+ static _hostDisplay = 'inline-flex';
26
+
27
+ #outsideClick = null;
28
+
29
+ connectedCallback() {
30
+ if (!this._isInitialized) {
31
+ this._markInitialized();
32
+ const shadow = this._shadow;
33
+
34
+ // ── Click delegation ──
35
+ shadow.addEventListener('click', (e) => {
36
+ // Trigger button: toggle menu
37
+ const trigger = e.target.closest('[data-floating-trigger]');
38
+ if (trigger && !this._props.disabled) {
39
+ if (this.#isOpen()) {
40
+ this.#closeMenu();
41
+ } else {
42
+ this.#openMenu();
43
+ }
44
+ return;
45
+ }
46
+
47
+ // Menu item click (regular menuitem): close and emit
48
+ const mi = e.target.closest('[role="menuitem"]');
49
+ if (mi && !mi.hasAttribute('aria-disabled')) {
50
+ const id = mi.getAttribute('data-item-id') || mi.textContent.trim();
51
+ this._emit('cx-action', { id });
52
+ this.#closeMenu();
53
+ return;
54
+ }
55
+
56
+ // Checkbox/radio items: toggle state but don't close
57
+ const mci = e.target.closest('[role="menuitemcheckbox"],[role="menuitemradio"]');
58
+ if (mci && !mci.hasAttribute('aria-disabled')) {
59
+ const checked = mci.getAttribute('aria-checked') === 'true';
60
+ mci.setAttribute('aria-checked', String(!checked));
61
+ this._emit('cx-change', {
62
+ id: mci.getAttribute('data-item-id') || mci.textContent.trim(),
63
+ checked: !checked,
64
+ });
65
+ }
66
+ });
67
+
68
+ // ── Keyboard navigation ──
69
+ shadow.addEventListener('keydown', (e) => {
70
+ const menuitem = e.target.closest('[role="menuitem"],[role="menuitemcheckbox"],[role="menuitemradio"]');
71
+
72
+ // Escape: close menu
73
+ if (e.key === 'Escape' && this.#isOpen()) {
74
+ e.preventDefault();
75
+ this.#closeMenu();
76
+ return;
77
+ }
78
+
79
+ // Arrow keys on trigger
80
+ if (e.target.closest('[data-floating-trigger]') && (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ')) {
81
+ if (!this.#isOpen()) {
82
+ e.preventDefault();
83
+ this.#openMenu();
84
+ return;
85
+ }
86
+ }
87
+
88
+ if (!menuitem) return;
89
+
90
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Home' || e.key === 'End') {
91
+ e.preventDefault();
92
+ const panel = shadow.querySelector('[data-menu]');
93
+ if (!panel) return;
94
+ const all = Array.from(panel.querySelectorAll(
95
+ '[role="menuitem"]:not([aria-disabled]),[role="menuitemcheckbox"]:not([aria-disabled]),[role="menuitemradio"]:not([aria-disabled])'
96
+ ));
97
+ const ci = all.indexOf(menuitem);
98
+ if (ci === -1) return;
99
+ let ni = ci;
100
+ if (e.key === 'ArrowDown') ni = (ci + 1) % all.length;
101
+ else if (e.key === 'ArrowUp') ni = (ci - 1 + all.length) % all.length;
102
+ else if (e.key === 'Home') ni = 0;
103
+ else if (e.key === 'End') ni = all.length - 1;
104
+ all[ni].focus();
105
+ return;
106
+ }
107
+
108
+ if (e.key === 'Enter' || e.key === ' ') {
109
+ e.preventDefault();
110
+ menuitem.click();
111
+ }
112
+ });
113
+
114
+ // ── Click outside → close (mousedown fires before click) ──
115
+ this.#outsideClick = (e) => {
116
+ if (this.#isOpen() && !this.contains(e.target) && !this._shadow.contains(e.target)) {
117
+ this.#closeMenu();
118
+ }
119
+ };
120
+ document.addEventListener('mousedown', this.#outsideClick);
121
+
122
+ // ── Focus exit → close ──
123
+ // Must check both light DOM and shadow DOM — Node.contains() doesn't cross shadow boundaries.
124
+ shadow.addEventListener('focusout', () => {
125
+ setTimeout(() => {
126
+ if (this.#isOpen()) {
127
+ const active = this._shadow.activeElement || document.activeElement;
128
+ if (!this.contains(active) && !this._shadow.contains(active) && active !== this) {
129
+ this.#closeMenu();
130
+ }
131
+ }
132
+ }, 0);
133
+ });
134
+ } // end _isInitialized guard
135
+
136
+ super.connectedCallback();
137
+ }
138
+
139
+ disconnectedCallback() {
140
+ if (this.#outsideClick) {
141
+ document.removeEventListener('mousedown', this.#outsideClick);
142
+ this.#outsideClick = null;
143
+ }
144
+ super.disconnectedCallback();
145
+ }
146
+
147
+ // ── State via DOM (single source of truth) ──
148
+
149
+ #isOpen() {
150
+ const trigger = this._shadow.querySelector('[data-floating-trigger]');
151
+ return trigger ? trigger.getAttribute('aria-expanded') === 'true' : false;
152
+ }
153
+
154
+ #openMenu() {
155
+ const panel = this._shadow.querySelector('[data-menu]');
156
+ const trigger = this._shadow.querySelector('[data-floating-trigger]');
157
+ if (!panel || !trigger) return;
158
+
159
+ // Position with fixed coordinates — escapes overflow:auto/scroll clipping.
160
+ this._positionFloatingFixed(trigger, panel, { matchWidth: true });
161
+
162
+ panel.setAttribute('data-open', '');
163
+ panel.classList.remove('hidden');
164
+ panel.style.display = 'block';
165
+ panel.style.pointerEvents = 'auto';
166
+ panel.style.opacity = '1';
167
+ trigger.setAttribute('aria-expanded', 'true');
168
+
169
+ // Focus first non-disabled item
170
+ requestAnimationFrame(() => {
171
+ const first = panel.querySelector(
172
+ '[role="menuitem"]:not([aria-disabled]),[role="menuitemcheckbox"]:not([aria-disabled]),[role="menuitemradio"]:not([aria-disabled])'
173
+ );
174
+ if (first) first.focus();
175
+ });
176
+ }
177
+
178
+ #closeMenu() {
179
+ const panel = this._shadow.querySelector('[data-menu]');
180
+ const trigger = this._shadow.querySelector('[data-floating-trigger]');
181
+ if (panel) {
182
+ panel.removeAttribute('data-open');
183
+ panel.classList.add('hidden');
184
+ panel.style.display = '';
185
+ panel.style.pointerEvents = '';
186
+ panel.style.opacity = '';
187
+ this._resetFloatingFixed(panel);
188
+ }
189
+ if (trigger) {
190
+ trigger.setAttribute('aria-expanded', 'false');
191
+ trigger.focus();
192
+ }
193
+ }
194
+
195
+ // ── Public imperative API ──
196
+ open() { this.#openMenu(); }
197
+ close() { this.#closeMenu(); }
198
+
199
+ _doRender() {
200
+ try {
201
+ const wasOpen = this.#isOpen();
202
+ const result = wasmFn(this._props);
203
+ this._injectHtml(result);
204
+ if (wasOpen) this.#openMenu();
205
+ } catch (e) {
206
+ console.error('[cx-profile-menu]', e);
207
+ }
208
+ }
209
+ }
210
+
211
+ customElements.define('cx-profile-menu', CxProfileMenu);
212
+ return CxProfileMenu;
213
+ }
@@ -0,0 +1,18 @@
1
+ // Auto-generated by scripts/generate-elements.mjs — DO NOT EDIT
2
+ // Source: crates/wasm-api/src/progress.rs
3
+
4
+ export interface CxProgressAttributes {
5
+ id?: string;
6
+ label?: string;
7
+ value?: number;
8
+ valueText?: string;
9
+ intent?: 'neutral' | 'primary' | 'info' | 'success' | 'warning' | 'danger';
10
+ shape?: 'rounded' | 'sharp' | 'pill';
11
+ size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
12
+ }
13
+
14
+ declare global {
15
+ interface HTMLElementTagNameMap {
16
+ 'cx-progress': HTMLElement & CxProgressAttributes;
17
+ }
18
+ }
@@ -0,0 +1,31 @@
1
+ // Auto-generated by scripts/generate-elements.mjs — DO NOT EDIT
2
+ // Source: crates/wasm-api/src/progress.rs
3
+
4
+ export function defineCxProgress(wasmFn, baseClass) {
5
+ class CxProgress extends baseClass {
6
+ static observedAttributes = ['id', 'label', 'value', 'value-text', 'intent', 'shape', 'size'];
7
+ static _booleanAttrs = new Set([]);
8
+ static _numericAttrs = new Set(['value']);
9
+ static _focusable = false;
10
+ static _hostDisplay = 'block';
11
+
12
+ set value(v) { this._setProp('value', v); }
13
+ get value() { return this._props.value; }
14
+
15
+ connectedCallback() {
16
+ super.connectedCallback();
17
+ }
18
+
19
+ _doRender() {
20
+ try {
21
+ const result = wasmFn(this._props);
22
+ this._injectHtml(result);
23
+ } catch (e) {
24
+ console.error('[cx-progress]', e);
25
+ }
26
+ }
27
+ }
28
+
29
+ customElements.define('cx-progress', CxProgress);
30
+ return CxProgress;
31
+ }
@@ -0,0 +1,22 @@
1
+ // Auto-generated by scripts/generate-elements.mjs — DO NOT EDIT
2
+ // Source: crates/wasm-api/src/radio_group.rs
3
+
4
+ export interface CxRadioGroupAttributes {
5
+ name?: string;
6
+ legend: string;
7
+ options?: string;
8
+ shape?: 'round' | 'rounded';
9
+ orientation?: 'vertical' | 'horizontal';
10
+ size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
11
+ selected?: string;
12
+ disabled?: boolean;
13
+ required?: boolean;
14
+ helperText?: string;
15
+ error?: string;
16
+ }
17
+
18
+ declare global {
19
+ interface HTMLElementTagNameMap {
20
+ 'cx-radio-group': HTMLElement & CxRadioGroupAttributes;
21
+ }
22
+ }
@@ -0,0 +1,70 @@
1
+ // Auto-generated by scripts/generate-elements.mjs — DO NOT EDIT
2
+ // Source: crates/wasm-api/src/radio_group.rs
3
+
4
+ export function defineCxRadioGroup(wasmFn, baseClass) {
5
+ class CxRadioGroup extends baseClass {
6
+ static observedAttributes = ['name', 'legend', 'options', 'shape', 'orientation', 'size', 'selected', 'disabled', 'required', 'helper-text', 'error'];
7
+ static _booleanAttrs = new Set(['disabled', 'required']);
8
+
9
+
10
+ connectedCallback() {
11
+ if (!this._isInitialized) {
12
+ this._markInitialized();
13
+ // Delegated event listeners — attach once on shadow root
14
+ this._shadow.addEventListener('input', (e) => {
15
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
16
+ this._setFormValue(e.target.value);
17
+ this._emit('cx-input', { value: e.target.value });
18
+ }
19
+ });
20
+ this._shadow.addEventListener('change', (e) => {
21
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') {
22
+ this._emit('cx-change', { value: e.target.value });
23
+ }
24
+ });
25
+
26
+ // Forward focus events from inner interactive elements
27
+ this._shadow.addEventListener('focusin', (e) => {
28
+ this._emit('cx-focus', { relatedTarget: e.relatedTarget });
29
+ });
30
+ this._shadow.addEventListener('focusout', (e) => {
31
+ this._emit('cx-blur', { relatedTarget: e.relatedTarget });
32
+ });
33
+
34
+ // Forward keyboard events from inner interactive elements
35
+ this._shadow.addEventListener('keydown', (e) => {
36
+ this._emit('cx-keydown', { key: e.key, code: e.code, shiftKey: e.shiftKey, ctrlKey: e.ctrlKey, altKey: e.altKey, metaKey: e.metaKey });
37
+ });
38
+ this._shadow.addEventListener('keyup', (e) => {
39
+ this._emit('cx-keyup', { key: e.key, code: e.code, shiftKey: e.shiftKey, ctrlKey: e.ctrlKey, altKey: e.altKey, metaKey: e.metaKey });
40
+ });
41
+ } // end _isInitialized guard
42
+ super.connectedCallback();
43
+ }
44
+
45
+ _doRender() {
46
+ try {
47
+ const result = wasmFn(this._props);
48
+ this._injectHtml(result);
49
+ // Sync form value after render. Event delegation on shadow root
50
+ // avoids duplicate listeners (innerHTML replaces old DOM nodes,
51
+ // but shadow root persists — delegation handles new inputs).
52
+ const input = this._shadow.querySelector('input, textarea, select');
53
+ if (input) {
54
+ // Sync controlled value prop → internal input property.
55
+ // _injectHtml's focus-preservation restores the OLD typed value
56
+ // to maintain cursor position during re-renders. But when the
57
+ // value prop explicitly changes (e.g., clearing after submit),
58
+ // the controlled value must win.
59
+ if ('value' in this._props) input.value = this._props.value;
60
+ this._setFormValue(input.value || '');
61
+ }
62
+ } catch (e) {
63
+ console.error('[cx-radio-group]', e);
64
+ }
65
+ }
66
+ }
67
+
68
+ customElements.define('cx-radio-group', CxRadioGroup);
69
+ return CxRadioGroup;
70
+ }
@@ -0,0 +1,19 @@
1
+ // Auto-generated by scripts/generate-elements.mjs — DO NOT EDIT
2
+ // Source: crates/wasm-api/src/scrollbar.rs
3
+
4
+ export interface CxScrollbarAttributes {
5
+ id?: string;
6
+ shape?: 'sharp' | 'rounded' | 'pill';
7
+ track?: 'with-track' | 'floating';
8
+ axis?: 'vertical' | 'horizontal' | 'both';
9
+ size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
10
+ height?: string;
11
+ content?: string;
12
+ label?: string;
13
+ }
14
+
15
+ declare global {
16
+ interface HTMLElementTagNameMap {
17
+ 'cx-scrollbar': HTMLElement & CxScrollbarAttributes;
18
+ }
19
+ }
@@ -0,0 +1,299 @@
1
+ // Custom behavior for <cx-scrollbar> — scroll tracking, thumb positioning, drag.
2
+ //
3
+ // Progressive enhancement:
4
+ // CSS-only: thumb visible at idle opacity (20%) with 30% height fallback
5
+ // JS-enhanced: precise thumb sizing, hide/show on scroll, drag support
6
+ //
7
+ // Sets data-scrollbar-ready on the container when behavior initializes.
8
+ // CSS uses this to switch from visible-fallback to hide-until-scroll mode.
9
+ //
10
+ // Source: crates/wasm-api/src/scrollbar.rs
11
+
12
+ export function defineCxScrollbar(wasmFn, baseClass) {
13
+ class CxScrollbar extends baseClass {
14
+ static observedAttributes = ['id', 'shape', 'track', 'axis', 'size', 'height', 'label'];
15
+ static _booleanAttrs = new Set([]);
16
+ static _focusable = false;
17
+ // Flex column + min-height:0 lets cx-scrollbar fill grid/flex cells.
18
+ // Without min-height:0, grid items default to min-height:auto and
19
+ // height:100% children inside Shadow DOM can't resolve the cell height.
20
+ static _hostDisplay = 'flex';
21
+ static _hostSheet = (() => {
22
+ if (typeof CSSStyleSheet === 'undefined') return null;
23
+ const s = new CSSStyleSheet();
24
+ s.replaceSync(':host { display: flex; flex-direction: column; min-height: 0; }');
25
+ return s;
26
+ })();
27
+
28
+ #sb = null;
29
+
30
+ connectedCallback() {
31
+ if (!this._isInitialized) {
32
+ this._markInitialized();
33
+ }
34
+ super.connectedCallback();
35
+ }
36
+
37
+ disconnectedCallback() {
38
+ this.#cleanup();
39
+ super.disconnectedCallback();
40
+ }
41
+
42
+ _doRender() {
43
+ try {
44
+ this._props.slotted = true;
45
+ const result = wasmFn(this._props);
46
+ this._injectHtml(result);
47
+ } catch (e) {
48
+ console.error('[cx-scrollbar] render:', e);
49
+ return;
50
+ }
51
+ // Init scrollbar behavior separately — errors here should not
52
+ // prevent the component from rendering its content.
53
+ try {
54
+ this.#initScrollbar();
55
+ } catch (e) {
56
+ console.error('[cx-scrollbar] init:', e);
57
+ // Attempt to set data-scrollbar-ready even on failure so CSS
58
+ // doesn't show a stale fallback thumb with no JS positioning.
59
+ const root = this._shadow.querySelector('[data-scrollbar]');
60
+ if (root) root.setAttribute('data-scrollbar-ready', '');
61
+ }
62
+ }
63
+
64
+ #initScrollbar() {
65
+ this.#cleanup();
66
+ const shadow = this._shadow;
67
+ const root = shadow.querySelector('[data-scrollbar]');
68
+ if (!root) return;
69
+
70
+ const vp = root.querySelector('[data-scrollbar-viewport]');
71
+ if (!vp) return;
72
+
73
+ const axis = root.getAttribute('data-scrollbar-axis') || 'y';
74
+ const isBoth = axis === 'both';
75
+
76
+ const sb = {
77
+ vp,
78
+ axis,
79
+ dragging: false,
80
+ hideTimer: 0,
81
+ tracks: [],
82
+ cleanups: [],
83
+ rafId: 0,
84
+ };
85
+ this.#sb = sb;
86
+
87
+ // Mark as JS-enhanced — CSS hides thumb when idle
88
+ root.setAttribute('data-scrollbar-ready', '');
89
+
90
+ // Build track entries
91
+ if (isBoth) {
92
+ this.#addTrack(root, sb, 'y',
93
+ root.querySelector('[data-scrollbar-track="y"]'),
94
+ root.querySelector('[data-scrollbar-thumb="y"]'));
95
+ this.#addTrack(root, sb, 'x',
96
+ root.querySelector('[data-scrollbar-track="x"]'),
97
+ root.querySelector('[data-scrollbar-thumb="x"]'));
98
+ } else {
99
+ this.#addTrack(root, sb, axis,
100
+ root.querySelector('[data-scrollbar-track]'),
101
+ root.querySelector('[data-scrollbar-thumb]'));
102
+ }
103
+
104
+ // Resize observer for content changes
105
+ if (typeof ResizeObserver !== 'undefined') {
106
+ const ro = new ResizeObserver(() => this.#updateAllThumbs(root, sb));
107
+ ro.observe(vp);
108
+ // For slotted content, observe assigned elements
109
+ const slot = vp.querySelector('slot');
110
+ if (slot) {
111
+ const assigned = slot.assignedElements();
112
+ if (assigned.length) ro.observe(assigned[0]);
113
+ slot.addEventListener('slotchange', () => {
114
+ const els = slot.assignedElements();
115
+ if (els.length) ro.observe(els[0]);
116
+ this.#updateAllThumbs(root, sb);
117
+ });
118
+ } else if (vp.firstElementChild) {
119
+ ro.observe(vp.firstElementChild);
120
+ }
121
+ sb.ro = ro;
122
+ }
123
+
124
+ // Scroll listener on viewport
125
+ const onScroll = () => this.#onScroll(root, sb);
126
+ vp.addEventListener('scroll', onScroll, { passive: true });
127
+ sb.cleanups.push(() => vp.removeEventListener('scroll', onScroll));
128
+
129
+ // Initial thumb calculation
130
+ this.#updateAllThumbs(root, sb);
131
+ }
132
+
133
+ #addTrack(root, sb, axisKey, track, thumb) {
134
+ if (!track || !thumb) return;
135
+ const isY = axisKey === 'y';
136
+ const entry = { track, thumb, isY };
137
+ sb.tracks.push(entry);
138
+
139
+ // Track hover
140
+ const enter = () => root.setAttribute('data-scroll-hover', '');
141
+ const leave = () => {
142
+ if (!sb.dragging) root.removeAttribute('data-scroll-hover');
143
+ };
144
+ track.addEventListener('mouseenter', enter);
145
+ track.addEventListener('mouseleave', leave);
146
+
147
+ // Thumb drag — mouse
148
+ const mouseDown = (e) => {
149
+ this.#startDrag(root, sb, entry, e.clientX, e.clientY, false);
150
+ e.preventDefault();
151
+ };
152
+ thumb.addEventListener('mousedown', mouseDown);
153
+
154
+ // Thumb drag — touch
155
+ const touchStart = (e) => {
156
+ if (e.touches.length === 1) {
157
+ this.#startDrag(root, sb, entry, e.touches[0].clientX, e.touches[0].clientY, true);
158
+ e.preventDefault();
159
+ }
160
+ };
161
+ thumb.addEventListener('touchstart', touchStart, { passive: false });
162
+
163
+ sb.cleanups.push(() => {
164
+ track.removeEventListener('mouseenter', enter);
165
+ track.removeEventListener('mouseleave', leave);
166
+ thumb.removeEventListener('mousedown', mouseDown);
167
+ thumb.removeEventListener('touchstart', touchStart);
168
+ });
169
+ }
170
+
171
+ #updateAllThumbs(root, sb) {
172
+ if (!sb) return;
173
+ for (const entry of sb.tracks) {
174
+ this.#updateThumb(sb, entry);
175
+ }
176
+ }
177
+
178
+ #updateThumb(sb, entry) {
179
+ if (!sb) return;
180
+ const vp = sb.vp;
181
+ const { thumb, isY } = entry;
182
+
183
+ const scrollSize = isY ? vp.scrollHeight : vp.scrollWidth;
184
+ const clientSize = isY ? vp.clientHeight : vp.clientWidth;
185
+
186
+ // No overflow — hide thumb entirely
187
+ if (scrollSize <= clientSize) {
188
+ thumb.style.display = 'none';
189
+ return;
190
+ }
191
+ thumb.style.display = '';
192
+
193
+ // Thumb size as percentage of track (minimum 10% for grabbability)
194
+ const ratio = clientSize / scrollSize;
195
+ const thumbPct = Math.max(ratio * 100, 10);
196
+
197
+ // Thumb position
198
+ const scrollPos = isY ? vp.scrollTop : vp.scrollLeft;
199
+ const maxScroll = scrollSize - clientSize;
200
+ const posPct = maxScroll > 0 ? (scrollPos / maxScroll) * (100 - thumbPct) : 0;
201
+
202
+ if (isY) {
203
+ thumb.style.top = posPct + '%';
204
+ thumb.style.height = thumbPct + '%';
205
+ thumb.style.left = '';
206
+ thumb.style.width = '';
207
+ } else {
208
+ thumb.style.left = posPct + '%';
209
+ thumb.style.width = thumbPct + '%';
210
+ thumb.style.top = '';
211
+ thumb.style.height = '';
212
+ }
213
+ }
214
+
215
+ #onScroll(root, sb) {
216
+ if (!sb) return;
217
+
218
+ root.setAttribute('data-scrolling', '');
219
+ clearTimeout(sb.hideTimer);
220
+ sb.hideTimer = setTimeout(() => {
221
+ if (!sb.dragging && !root.hasAttribute('data-scroll-hover')) {
222
+ root.removeAttribute('data-scrolling');
223
+ }
224
+ }, 1200);
225
+
226
+ if (sb.rafId) return;
227
+ sb.rafId = requestAnimationFrame(() => {
228
+ sb.rafId = 0;
229
+ this.#updateAllThumbs(root, sb);
230
+ });
231
+ }
232
+
233
+ #startDrag(root, sb, entry, startX, startY, isTouch) {
234
+ if (!sb) return;
235
+ sb.dragging = true;
236
+ root.setAttribute('data-scroll-active', '');
237
+
238
+ const { isY } = entry;
239
+ const startMouse = isY ? startY : startX;
240
+ const startScroll = isY ? sb.vp.scrollTop : sb.vp.scrollLeft;
241
+ const trackSize = isY ? entry.track.clientHeight : entry.track.clientWidth;
242
+ const scrollSize = isY ? sb.vp.scrollHeight : sb.vp.scrollWidth;
243
+ const clientSize = isY ? sb.vp.clientHeight : sb.vp.clientWidth;
244
+ const maxScroll = scrollSize - clientSize;
245
+
246
+ const onMove = (ev) => {
247
+ let pos;
248
+ if (isTouch) {
249
+ if (ev.touches.length === 0) return;
250
+ pos = isY ? ev.touches[0].clientY : ev.touches[0].clientX;
251
+ } else {
252
+ pos = isY ? ev.clientY : ev.clientX;
253
+ }
254
+ const delta = pos - startMouse;
255
+ const scrollDelta = (delta / trackSize) * scrollSize;
256
+ const newScroll = Math.max(0, Math.min(maxScroll, startScroll + scrollDelta));
257
+ if (isY) sb.vp.scrollTop = newScroll;
258
+ else sb.vp.scrollLeft = newScroll;
259
+ };
260
+
261
+ const onUp = () => {
262
+ sb.dragging = false;
263
+ root.removeAttribute('data-scroll-active');
264
+ if (!root.querySelector('[data-scrollbar-track]:hover')) {
265
+ root.removeAttribute('data-scroll-hover');
266
+ }
267
+ if (isTouch) {
268
+ document.removeEventListener('touchmove', onMove);
269
+ document.removeEventListener('touchend', onUp);
270
+ document.removeEventListener('touchcancel', onUp);
271
+ } else {
272
+ document.removeEventListener('mousemove', onMove);
273
+ document.removeEventListener('mouseup', onUp);
274
+ }
275
+ };
276
+
277
+ if (isTouch) {
278
+ document.addEventListener('touchmove', onMove, { passive: false });
279
+ document.addEventListener('touchend', onUp);
280
+ document.addEventListener('touchcancel', onUp);
281
+ } else {
282
+ document.addEventListener('mousemove', onMove);
283
+ document.addEventListener('mouseup', onUp);
284
+ }
285
+ }
286
+
287
+ #cleanup() {
288
+ const sb = this.#sb;
289
+ if (!sb) return;
290
+ if (sb.ro) sb.ro.disconnect();
291
+ clearTimeout(sb.hideTimer);
292
+ for (const fn of sb.cleanups) fn();
293
+ this.#sb = null;
294
+ }
295
+ }
296
+
297
+ customElements.define('cx-scrollbar', CxScrollbar);
298
+ return CxScrollbar;
299
+ }