@bquery/bquery 1.7.0 → 1.8.2

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 (262) hide show
  1. package/README.md +760 -716
  2. package/dist/{a11y-C5QOVvRn.js → a11y-DVBCy09c.js} +3 -3
  3. package/dist/a11y-DVBCy09c.js.map +1 -0
  4. package/dist/a11y.es.mjs +1 -1
  5. package/dist/component/library.d.ts.map +1 -1
  6. package/dist/{component-CuuTijA6.js → component-L3-JfOFz.js} +5 -5
  7. package/dist/component-L3-JfOFz.js.map +1 -0
  8. package/dist/component.es.mjs +1 -1
  9. package/dist/{config-BW35FKuA.js → config-DhT9auRm.js} +1 -1
  10. package/dist/{config-BW35FKuA.js.map → config-DhT9auRm.js.map} +1 -1
  11. package/dist/{constraints-3lV9yyBw.js → constraints-D5RHQLmP.js} +1 -1
  12. package/dist/constraints-D5RHQLmP.js.map +1 -0
  13. package/dist/core/collection.d.ts +86 -0
  14. package/dist/core/collection.d.ts.map +1 -1
  15. package/dist/core/element.d.ts +28 -0
  16. package/dist/core/element.d.ts.map +1 -1
  17. package/dist/core/shared.d.ts +6 -0
  18. package/dist/core/shared.d.ts.map +1 -1
  19. package/dist/core-DdtZHzsS.js +168 -0
  20. package/dist/core-DdtZHzsS.js.map +1 -0
  21. package/dist/{core-Cjl7GUu8.js → core-EMYSLzaT.js} +289 -259
  22. package/dist/core-EMYSLzaT.js.map +1 -0
  23. package/dist/core.es.mjs +48 -47
  24. package/dist/{custom-directives-7wAShnnd.js → custom-directives-Dr4C5lVV.js} +1 -1
  25. package/dist/custom-directives-Dr4C5lVV.js.map +1 -0
  26. package/dist/{devtools-D2fQLhDN.js → devtools-BhB2iDPT.js} +2 -2
  27. package/dist/devtools-BhB2iDPT.js.map +1 -0
  28. package/dist/devtools.es.mjs +1 -1
  29. package/dist/{dnd-B8EgyzaI.js → dnd-NwZBYh4l.js} +1 -1
  30. package/dist/dnd-NwZBYh4l.js.map +1 -0
  31. package/dist/dnd.es.mjs +1 -1
  32. package/dist/{env-NeVmr4Gf.js → env-CTdvLaH2.js} +1 -1
  33. package/dist/env-CTdvLaH2.js.map +1 -0
  34. package/dist/forms/create-form.d.ts.map +1 -1
  35. package/dist/forms/index.d.ts +3 -2
  36. package/dist/forms/index.d.ts.map +1 -1
  37. package/dist/forms/types.d.ts +46 -0
  38. package/dist/forms/types.d.ts.map +1 -1
  39. package/dist/forms/use-field.d.ts +34 -0
  40. package/dist/forms/use-field.d.ts.map +1 -0
  41. package/dist/forms/validators.d.ts +25 -0
  42. package/dist/forms/validators.d.ts.map +1 -1
  43. package/dist/forms-UcRHsYxC.js +227 -0
  44. package/dist/forms-UcRHsYxC.js.map +1 -0
  45. package/dist/forms.es.mjs +14 -12
  46. package/dist/full.d.ts +17 -26
  47. package/dist/full.d.ts.map +1 -1
  48. package/dist/full.es.mjs +206 -181
  49. package/dist/full.iife.js +33 -33
  50. package/dist/full.iife.js.map +1 -1
  51. package/dist/full.umd.js +33 -33
  52. package/dist/full.umd.js.map +1 -1
  53. package/dist/function-Cybd57JV.js +33 -0
  54. package/dist/function-Cybd57JV.js.map +1 -0
  55. package/dist/{i18n-BnnhTFOS.js → i18n-kuF6Ekj6.js} +3 -3
  56. package/dist/i18n-kuF6Ekj6.js.map +1 -0
  57. package/dist/i18n.es.mjs +1 -1
  58. package/dist/index.es.mjs +251 -228
  59. package/dist/media/breakpoints.d.ts.map +1 -1
  60. package/dist/media/types.d.ts +2 -2
  61. package/dist/media/types.d.ts.map +1 -1
  62. package/dist/{media-Di2Ta22s.js → media-i-fB5WxI.js} +3 -3
  63. package/dist/media-i-fB5WxI.js.map +1 -0
  64. package/dist/media.es.mjs +1 -1
  65. package/dist/{motion-qPj_TYGv.js → motion-BJsAuULb.js} +2 -2
  66. package/dist/motion-BJsAuULb.js.map +1 -0
  67. package/dist/motion.es.mjs +1 -1
  68. package/dist/{mount-SM07RUa6.js → mount-B4Y8bk8Z.js} +5 -5
  69. package/dist/mount-B4Y8bk8Z.js.map +1 -0
  70. package/dist/{platform-CPbCprb6.js → platform-Dw2gE3zI.js} +3 -3
  71. package/dist/{platform-CPbCprb6.js.map → platform-Dw2gE3zI.js.map} +1 -1
  72. package/dist/platform.es.mjs +2 -2
  73. package/dist/plugin/registry.d.ts.map +1 -1
  74. package/dist/{plugin-cPoOHFLY.js → plugin-C2WuC8SF.js} +20 -18
  75. package/dist/plugin-C2WuC8SF.js.map +1 -0
  76. package/dist/plugin.es.mjs +1 -1
  77. package/dist/reactive/async-data.d.ts +28 -3
  78. package/dist/reactive/async-data.d.ts.map +1 -1
  79. package/dist/reactive/computed.d.ts +3 -0
  80. package/dist/reactive/computed.d.ts.map +1 -1
  81. package/dist/reactive/effect.d.ts +3 -0
  82. package/dist/reactive/effect.d.ts.map +1 -1
  83. package/dist/reactive/http.d.ts +194 -0
  84. package/dist/reactive/http.d.ts.map +1 -0
  85. package/dist/reactive/index.d.ts +2 -2
  86. package/dist/reactive/index.d.ts.map +1 -1
  87. package/dist/reactive/pagination.d.ts +126 -0
  88. package/dist/reactive/pagination.d.ts.map +1 -0
  89. package/dist/reactive/polling.d.ts +55 -0
  90. package/dist/reactive/polling.d.ts.map +1 -0
  91. package/dist/reactive/readonly.d.ts +20 -1
  92. package/dist/reactive/readonly.d.ts.map +1 -1
  93. package/dist/reactive/rest.d.ts +293 -0
  94. package/dist/reactive/rest.d.ts.map +1 -0
  95. package/dist/reactive/scope.d.ts +140 -0
  96. package/dist/reactive/scope.d.ts.map +1 -0
  97. package/dist/reactive/signal.d.ts +16 -2
  98. package/dist/reactive/signal.d.ts.map +1 -1
  99. package/dist/reactive/to-value.d.ts +57 -0
  100. package/dist/reactive/to-value.d.ts.map +1 -0
  101. package/dist/reactive/websocket.d.ts +285 -0
  102. package/dist/reactive/websocket.d.ts.map +1 -0
  103. package/dist/reactive-DwkhUJfP.js +1148 -0
  104. package/dist/reactive-DwkhUJfP.js.map +1 -0
  105. package/dist/reactive.es.mjs +38 -19
  106. package/dist/{registry-CWf368tT.js → registry-B08iilIh.js} +1 -1
  107. package/dist/{registry-CWf368tT.js.map → registry-B08iilIh.js.map} +1 -1
  108. package/dist/router/constraints.d.ts.map +1 -1
  109. package/dist/router/index.d.ts +1 -1
  110. package/dist/router/index.d.ts.map +1 -1
  111. package/dist/router/router.d.ts.map +1 -1
  112. package/dist/router/state.d.ts +25 -2
  113. package/dist/router/state.d.ts.map +1 -1
  114. package/dist/router-CQikC9Ed.js +492 -0
  115. package/dist/router-CQikC9Ed.js.map +1 -0
  116. package/dist/router.es.mjs +9 -8
  117. package/dist/ssr/hydrate.d.ts.map +1 -1
  118. package/dist/{ssr-B2qd_WBB.js → ssr-_dAcGdzu.js} +4 -4
  119. package/dist/ssr-_dAcGdzu.js.map +1 -0
  120. package/dist/ssr.es.mjs +1 -1
  121. package/dist/store/persisted.d.ts.map +1 -1
  122. package/dist/{store-DWpyH6p5.js → store-Cb3gPRve.js} +7 -7
  123. package/dist/store-Cb3gPRve.js.map +1 -0
  124. package/dist/store.es.mjs +2 -2
  125. package/dist/storybook.es.mjs.map +1 -1
  126. package/dist/{testing-CsqjNUyy.js → testing-C5Sjfsna.js} +8 -8
  127. package/dist/testing-C5Sjfsna.js.map +1 -0
  128. package/dist/testing.es.mjs +1 -1
  129. package/dist/{type-guards-Do9DWgNp.js → type-guards-BMX2c0LP.js} +1 -1
  130. package/dist/{type-guards-Do9DWgNp.js.map → type-guards-BMX2c0LP.js.map} +1 -1
  131. package/dist/untrack-D0fnO5k2.js +36 -0
  132. package/dist/untrack-D0fnO5k2.js.map +1 -0
  133. package/dist/view/custom-directives.d.ts.map +1 -1
  134. package/dist/view.es.mjs +4 -4
  135. package/package.json +178 -177
  136. package/src/a11y/announce.ts +131 -131
  137. package/src/a11y/audit.ts +314 -314
  138. package/src/a11y/index.ts +68 -68
  139. package/src/a11y/media-preferences.ts +255 -255
  140. package/src/a11y/roving-tab-index.ts +164 -164
  141. package/src/a11y/skip-link.ts +255 -255
  142. package/src/a11y/trap-focus.ts +184 -184
  143. package/src/a11y/types.ts +183 -183
  144. package/src/component/component.ts +599 -599
  145. package/src/component/html.ts +153 -153
  146. package/src/component/index.ts +52 -52
  147. package/src/component/library.ts +540 -542
  148. package/src/component/scope.ts +212 -212
  149. package/src/component/types.ts +310 -310
  150. package/src/core/collection.ts +876 -707
  151. package/src/core/element.ts +1015 -981
  152. package/src/core/env.ts +60 -60
  153. package/src/core/index.ts +49 -49
  154. package/src/core/shared.ts +77 -62
  155. package/src/core/utils/index.ts +148 -148
  156. package/src/devtools/devtools.ts +410 -410
  157. package/src/devtools/index.ts +48 -48
  158. package/src/devtools/types.ts +104 -104
  159. package/src/dnd/draggable.ts +296 -296
  160. package/src/dnd/droppable.ts +228 -228
  161. package/src/dnd/index.ts +62 -62
  162. package/src/dnd/sortable.ts +307 -307
  163. package/src/dnd/types.ts +293 -293
  164. package/src/forms/create-form.ts +320 -278
  165. package/src/forms/index.ts +70 -65
  166. package/src/forms/types.ts +203 -154
  167. package/src/forms/use-field.ts +231 -0
  168. package/src/forms/validators.ts +294 -265
  169. package/src/full.ts +554 -480
  170. package/src/i18n/formatting.ts +67 -67
  171. package/src/i18n/i18n.ts +200 -200
  172. package/src/i18n/index.ts +67 -67
  173. package/src/i18n/translate.ts +182 -182
  174. package/src/i18n/types.ts +171 -171
  175. package/src/index.ts +108 -108
  176. package/src/media/battery.ts +116 -116
  177. package/src/media/breakpoints.ts +129 -131
  178. package/src/media/clipboard.ts +80 -80
  179. package/src/media/device-sensors.ts +158 -158
  180. package/src/media/geolocation.ts +119 -119
  181. package/src/media/index.ts +76 -76
  182. package/src/media/media-query.ts +92 -92
  183. package/src/media/network.ts +115 -115
  184. package/src/media/types.ts +177 -177
  185. package/src/media/viewport.ts +84 -84
  186. package/src/motion/index.ts +57 -57
  187. package/src/motion/morph.ts +151 -151
  188. package/src/motion/parallax.ts +120 -120
  189. package/src/motion/reduced-motion.ts +66 -66
  190. package/src/motion/types.ts +271 -271
  191. package/src/motion/typewriter.ts +164 -164
  192. package/src/plugin/index.ts +37 -37
  193. package/src/plugin/registry.ts +284 -269
  194. package/src/plugin/types.ts +137 -137
  195. package/src/reactive/async-data.ts +250 -29
  196. package/src/reactive/computed.ts +144 -130
  197. package/src/reactive/effect.ts +29 -6
  198. package/src/reactive/http.ts +790 -0
  199. package/src/reactive/index.ts +60 -0
  200. package/src/reactive/pagination.ts +317 -0
  201. package/src/reactive/polling.ts +179 -0
  202. package/src/reactive/readonly.ts +52 -8
  203. package/src/reactive/rest.ts +859 -0
  204. package/src/reactive/scope.ts +276 -0
  205. package/src/reactive/signal.ts +61 -1
  206. package/src/reactive/to-value.ts +71 -0
  207. package/src/reactive/websocket.ts +849 -0
  208. package/src/router/bq-link.ts +279 -279
  209. package/src/router/constraints.ts +204 -201
  210. package/src/router/index.ts +49 -49
  211. package/src/router/match.ts +312 -312
  212. package/src/router/path-pattern.ts +52 -52
  213. package/src/router/query.ts +38 -38
  214. package/src/router/router.ts +421 -402
  215. package/src/router/state.ts +51 -3
  216. package/src/router/types.ts +139 -139
  217. package/src/router/use-route.ts +68 -68
  218. package/src/router/utils.ts +157 -157
  219. package/src/security/index.ts +12 -12
  220. package/src/ssr/hydrate.ts +84 -82
  221. package/src/ssr/index.ts +70 -70
  222. package/src/ssr/render.ts +508 -508
  223. package/src/ssr/serialize.ts +296 -296
  224. package/src/ssr/types.ts +81 -81
  225. package/src/store/create-store.ts +467 -467
  226. package/src/store/index.ts +27 -27
  227. package/src/store/persisted.ts +245 -249
  228. package/src/store/types.ts +247 -247
  229. package/src/store/utils.ts +135 -135
  230. package/src/storybook/index.ts +480 -480
  231. package/src/testing/index.ts +42 -42
  232. package/src/testing/testing.ts +593 -593
  233. package/src/testing/types.ts +170 -170
  234. package/src/view/custom-directives.ts +28 -30
  235. package/src/view/evaluate.ts +292 -292
  236. package/src/view/process.ts +108 -108
  237. package/dist/a11y-C5QOVvRn.js.map +0 -1
  238. package/dist/component-CuuTijA6.js.map +0 -1
  239. package/dist/constraints-3lV9yyBw.js.map +0 -1
  240. package/dist/core-Cjl7GUu8.js.map +0 -1
  241. package/dist/core-DnlyjbF2.js +0 -112
  242. package/dist/core-DnlyjbF2.js.map +0 -1
  243. package/dist/custom-directives-7wAShnnd.js.map +0 -1
  244. package/dist/devtools-D2fQLhDN.js.map +0 -1
  245. package/dist/dnd-B8EgyzaI.js.map +0 -1
  246. package/dist/env-NeVmr4Gf.js.map +0 -1
  247. package/dist/forms-C3yovgH9.js +0 -141
  248. package/dist/forms-C3yovgH9.js.map +0 -1
  249. package/dist/i18n-BnnhTFOS.js.map +0 -1
  250. package/dist/media-Di2Ta22s.js.map +0 -1
  251. package/dist/motion-qPj_TYGv.js.map +0 -1
  252. package/dist/mount-SM07RUa6.js.map +0 -1
  253. package/dist/plugin-cPoOHFLY.js.map +0 -1
  254. package/dist/reactive-Cfv0RK6x.js +0 -233
  255. package/dist/reactive-Cfv0RK6x.js.map +0 -1
  256. package/dist/router-BrthaP_z.js +0 -473
  257. package/dist/router-BrthaP_z.js.map +0 -1
  258. package/dist/ssr-B2qd_WBB.js.map +0 -1
  259. package/dist/store-DWpyH6p5.js.map +0 -1
  260. package/dist/testing-CsqjNUyy.js.map +0 -1
  261. package/dist/untrack-DJVQQ2WM.js +0 -33
  262. package/dist/untrack-DJVQQ2WM.js.map +0 -1
@@ -1,707 +1,876 @@
1
- import {
2
- createElementFromHtml,
3
- insertContent,
4
- sanitizeContent,
5
- type InsertableContent,
6
- } from './dom';
7
- import { BQueryElement } from './element';
8
- import { applyAll, getOuterSize, isHTMLElement, toElementList } from './shared';
9
-
10
- /** Handler signature for delegated events */
11
- type DelegatedHandler = (event: Event, target: Element) => void;
12
-
13
- /**
14
- * Wrapper for multiple DOM elements.
15
- * Provides batch operations on a collection of elements with chainable API.
16
- *
17
- * This class enables jQuery-like operations across multiple elements:
18
- * - All mutating methods apply to every element in the collection
19
- * - Getter methods return data from the first element
20
- * - Supports iteration via forEach, map, filter, and reduce
21
- *
22
- * @example
23
- * ```ts
24
- * $$('.items')
25
- * .addClass('highlight')
26
- * .css({ opacity: '0.8' })
27
- * .on('click', () => console.log('clicked'));
28
- * ```
29
- */
30
- export class BQueryCollection {
31
- /**
32
- * Stores delegated event handlers for cleanup via undelegate().
33
- * Outer map: element -> (key -> (handler -> wrapper))
34
- * Key format: `${event}:${selector}`
35
- * @internal
36
- */
37
- private readonly delegatedHandlers = new WeakMap<
38
- Element,
39
- Map<string, Map<DelegatedHandler, EventListener>>
40
- >();
41
-
42
- /**
43
- * Creates a new collection wrapper.
44
- * @param elements - Array of DOM elements to wrap
45
- */
46
- constructor(public readonly elements: Element[]) {}
47
-
48
- /**
49
- * Gets the number of elements in the collection.
50
- */
51
- get length(): number {
52
- return this.elements.length;
53
- }
54
-
55
- /**
56
- * Gets the first element in the collection, if any.
57
- * @internal
58
- */
59
- private first(): Element | undefined {
60
- return this.elements[0];
61
- }
62
-
63
- /**
64
- * Gets a single element as a BQueryElement wrapper.
65
- *
66
- * @param index - Zero-based index of the element
67
- * @returns BQueryElement wrapper or undefined if out of range
68
- */
69
- eq(index: number): BQueryElement | undefined {
70
- const el = this.elements[index];
71
- return el ? new BQueryElement(el) : undefined;
72
- }
73
-
74
- /**
75
- * Gets the first element as a BQueryElement wrapper.
76
- *
77
- * @returns BQueryElement wrapper or undefined if empty
78
- */
79
- firstEl(): BQueryElement | undefined {
80
- return this.eq(0);
81
- }
82
-
83
- /**
84
- * Gets the last element as a BQueryElement wrapper.
85
- *
86
- * @returns BQueryElement wrapper or undefined if empty
87
- */
88
- lastEl(): BQueryElement | undefined {
89
- return this.eq(this.elements.length - 1);
90
- }
91
-
92
- /**
93
- * Iterates over each element in the collection.
94
- *
95
- * @param callback - Function to call for each wrapped element
96
- * @returns The instance for method chaining
97
- */
98
- each(callback: (element: BQueryElement, index: number) => void): this {
99
- this.elements.forEach((element, index) => {
100
- callback(new BQueryElement(element), index);
101
- });
102
- return this;
103
- }
104
-
105
- /**
106
- * Maps each element to a new value.
107
- *
108
- * @param callback - Function to transform each element
109
- * @returns Array of transformed values
110
- */
111
- map<T>(callback: (element: Element, index: number) => T): T[] {
112
- return this.elements.map(callback);
113
- }
114
-
115
- /**
116
- * Filters elements based on a predicate.
117
- *
118
- * @param predicate - Function to test each element
119
- * @returns New BQueryCollection with matching elements
120
- */
121
- filter(predicate: (element: Element, index: number) => boolean): BQueryCollection {
122
- return new BQueryCollection(this.elements.filter(predicate));
123
- }
124
-
125
- /**
126
- * Reduces the collection to a single value.
127
- *
128
- * @param callback - Reducer function
129
- * @param initialValue - Initial accumulator value
130
- * @returns Accumulated result
131
- */
132
- reduce<T>(callback: (accumulator: T, element: Element, index: number) => T, initialValue: T): T {
133
- return this.elements.reduce(callback, initialValue);
134
- }
135
-
136
- /**
137
- * Converts the collection to an array of BQueryElement wrappers.
138
- *
139
- * @returns Array of BQueryElement instances
140
- */
141
- toArray(): BQueryElement[] {
142
- return this.elements.map((el) => new BQueryElement(el));
143
- }
144
-
145
- /** Add one or more classes to all elements. */
146
- addClass(...classNames: string[]): this {
147
- applyAll(this.elements, (el) => el.classList.add(...classNames));
148
- return this;
149
- }
150
-
151
- /** Remove one or more classes from all elements. */
152
- removeClass(...classNames: string[]): this {
153
- applyAll(this.elements, (el) => el.classList.remove(...classNames));
154
- return this;
155
- }
156
-
157
- /** Toggle a class on all elements. */
158
- toggleClass(className: string, force?: boolean): this {
159
- applyAll(this.elements, (el) => el.classList.toggle(className, force));
160
- return this;
161
- }
162
-
163
- /**
164
- * Sets an attribute on all elements or gets from first.
165
- *
166
- * @param name - Attribute name
167
- * @param value - Value to set (optional)
168
- * @returns Attribute value when getting, instance when setting
169
- */
170
- attr(name: string, value?: string): string | this {
171
- if (value === undefined) {
172
- return this.first()?.getAttribute(name) ?? '';
173
- }
174
- applyAll(this.elements, (el) => el.setAttribute(name, value));
175
- return this;
176
- }
177
-
178
- /**
179
- * Removes an attribute from all elements.
180
- *
181
- * @param name - Attribute name to remove
182
- * @returns The instance for method chaining
183
- */
184
- removeAttr(name: string): this {
185
- applyAll(this.elements, (el) => el.removeAttribute(name));
186
- return this;
187
- }
188
-
189
- /** Toggle an attribute on all elements. */
190
- toggleAttr(name: string, force?: boolean): this {
191
- applyAll(this.elements, (el) => {
192
- const hasAttr = el.hasAttribute(name);
193
- const shouldAdd = force ?? !hasAttr;
194
- if (shouldAdd) {
195
- el.setAttribute(name, '');
196
- } else {
197
- el.removeAttribute(name);
198
- }
199
- });
200
- return this;
201
- }
202
-
203
- /**
204
- * Sets text content on all elements or gets from first.
205
- *
206
- * @param value - Text to set (optional)
207
- * @returns Text content when getting, instance when setting
208
- */
209
- text(value?: string): string | this {
210
- if (value === undefined) {
211
- return this.first()?.textContent ?? '';
212
- }
213
- applyAll(this.elements, (el) => {
214
- el.textContent = value;
215
- });
216
- return this;
217
- }
218
-
219
- /**
220
- * Sets sanitized HTML on all elements or gets from first.
221
- *
222
- * @param value - HTML to set (optional, will be sanitized)
223
- * @returns HTML content when getting, instance when setting
224
- */
225
- html(value?: string): string | this {
226
- if (value === undefined) {
227
- return this.first()?.innerHTML ?? '';
228
- }
229
- const sanitized = sanitizeContent(value);
230
- applyAll(this.elements, (el) => {
231
- el.innerHTML = sanitized;
232
- });
233
- return this;
234
- }
235
-
236
- /**
237
- * Sets HTML on all elements without sanitization.
238
- *
239
- * @param value - Raw HTML to set
240
- * @returns The instance for method chaining
241
- * @warning Bypasses XSS protection
242
- */
243
- htmlUnsafe(value: string): this {
244
- applyAll(this.elements, (el) => {
245
- el.innerHTML = value;
246
- });
247
- return this;
248
- }
249
-
250
- /** Append content to all elements. */
251
- append(content: InsertableContent): this {
252
- this.insertAll(content, 'beforeend');
253
- return this;
254
- }
255
-
256
- /** Prepend content to all elements. */
257
- prepend(content: InsertableContent): this {
258
- this.insertAll(content, 'afterbegin');
259
- return this;
260
- }
261
-
262
- /** Insert content before all elements. */
263
- before(content: InsertableContent): this {
264
- this.insertAll(content, 'beforebegin');
265
- return this;
266
- }
267
-
268
- /** Insert content after all elements. */
269
- after(content: InsertableContent): this {
270
- this.insertAll(content, 'afterend');
271
- return this;
272
- }
273
-
274
- /**
275
- * Gets or sets CSS styles on all elements.
276
- * When getting, returns the computed style value from the first element.
277
- *
278
- * @param property - Property name or object of properties
279
- * @param value - Value when setting single property
280
- * @returns The computed style value when getting, instance when setting
281
- */
282
- css(property: string): string;
283
- css(property: string, value: string): this;
284
- css(property: Record<string, string>): this;
285
- css(property: string | Record<string, string>, value?: string): string | this {
286
- if (typeof property === 'string') {
287
- if (value !== undefined) {
288
- applyAll(this.elements, (el) => {
289
- (el as HTMLElement).style.setProperty(property, value);
290
- });
291
- return this;
292
- }
293
- const first = this.first();
294
- if (!first) {
295
- return '';
296
- }
297
- const view = first.ownerDocument?.defaultView;
298
- if (!view || typeof view.getComputedStyle !== 'function') {
299
- return '';
300
- }
301
- return view.getComputedStyle(first).getPropertyValue(property);
302
- }
303
-
304
- applyAll(this.elements, (el) => {
305
- for (const [key, val] of Object.entries(property)) {
306
- (el as HTMLElement).style.setProperty(key, val);
307
- }
308
- });
309
- return this;
310
- }
311
-
312
- /** Wrap each element with a wrapper element or tag. */
313
- wrap(wrapper: string | Element): this {
314
- this.elements.forEach((el, index) => {
315
- const wrapperEl =
316
- typeof wrapper === 'string'
317
- ? document.createElement(wrapper)
318
- : index === 0
319
- ? wrapper
320
- : (wrapper.cloneNode(true) as Element);
321
- el.parentNode?.insertBefore(wrapperEl, el);
322
- wrapperEl.appendChild(el);
323
- });
324
- return this;
325
- }
326
-
327
- /**
328
- * Remove the parent element of each element, keeping the elements in place.
329
- *
330
- * **Important**: This method unwraps ALL children of each parent element,
331
- * not just the elements in the collection. If you call `unwrap()` on a
332
- * collection containing only some children of a parent, all siblings will
333
- * also be unwrapped. This behavior is consistent with jQuery's `.unwrap()`.
334
- *
335
- * @returns The collection for chaining
336
- *
337
- * @example
338
- * ```ts
339
- * // HTML: <div><section><span>A</span><span>B</span></section></div>
340
- * const spans = $$('span');
341
- * spans.unwrap(); // Removes <section>, both spans move to <div>
342
- * // Result: <div><span>A</span><span>B</span></div>
343
- * ```
344
- */
345
- unwrap(): this {
346
- // Collect unique parent elements to avoid removing the same parent multiple times.
347
- const parents = new Set<Element>();
348
- for (const el of this.elements) {
349
- if (el.parentElement) {
350
- parents.add(el.parentElement);
351
- }
352
- }
353
-
354
- // Unwrap each parent once: move all children out, then remove the wrapper.
355
- parents.forEach((parent) => {
356
- const grandParent = parent.parentNode;
357
- if (!grandParent) return;
358
-
359
- while (parent.firstChild) {
360
- grandParent.insertBefore(parent.firstChild, parent);
361
- }
362
-
363
- parent.remove();
364
- });
365
- return this;
366
- }
367
-
368
- /** Replace each element with provided content. */
369
- replaceWith(content: string | Element): BQueryCollection {
370
- const replacements: Element[] = [];
371
- this.elements.forEach((el, index) => {
372
- const replacement =
373
- typeof content === 'string'
374
- ? createElementFromHtml(content)
375
- : index === 0
376
- ? content
377
- : (content.cloneNode(true) as Element);
378
- el.replaceWith(replacement);
379
- replacements.push(replacement);
380
- });
381
- return new BQueryCollection(replacements);
382
- }
383
-
384
- /**
385
- * Removes all elements from the DOM while keeping the wrapped nodes available
386
- * for later reuse.
387
- *
388
- * @returns The instance for method chaining
389
- */
390
- detach(): this {
391
- return this.remove();
392
- }
393
-
394
- /**
395
- * Gets the zero-based sibling index of the first element in the collection.
396
- *
397
- * @returns Index of the first element, or -1 when unavailable
398
- */
399
- index(): number {
400
- const first = this.first();
401
- if (!first?.parentElement) {
402
- return -1;
403
- }
404
- return Array.from(first.parentElement.children).indexOf(first);
405
- }
406
-
407
- /**
408
- * Returns the child nodes of the first element, including text nodes and comments.
409
- *
410
- * @returns Array of child nodes from the first element
411
- */
412
- contents(): ChildNode[] {
413
- return Array.from(this.first()?.childNodes ?? []);
414
- }
415
-
416
- /**
417
- * Gets the offset parent of the first element in the collection.
418
- *
419
- * @returns Offset parent element, or null when unavailable
420
- */
421
- offsetParent(): Element | null {
422
- const first = this.first();
423
- return isHTMLElement(first) ? first.offsetParent : null;
424
- }
425
-
426
- /**
427
- * Gets the position of the first element relative to its offset parent.
428
- *
429
- * @returns Position object with top and left coordinates
430
- */
431
- position(): { top: number; left: number } {
432
- const first = this.first();
433
- if (!isHTMLElement(first)) {
434
- return { top: 0, left: 0 };
435
- }
436
-
437
- return {
438
- top: first.offsetTop,
439
- left: first.offsetLeft,
440
- };
441
- }
442
-
443
- /**
444
- * Gets the outer width of the first element, optionally including margins.
445
- *
446
- * @param includeMargin - When true, include horizontal margins
447
- * @returns Outer width in pixels
448
- */
449
- outerWidth(includeMargin: boolean = false): number {
450
- return getOuterSize(this.first(), 'width', includeMargin);
451
- }
452
-
453
- /**
454
- * Gets the outer height of the first element, optionally including margins.
455
- *
456
- * @param includeMargin - When true, include vertical margins
457
- * @returns Outer height in pixels
458
- */
459
- outerHeight(includeMargin: boolean = false): number {
460
- return getOuterSize(this.first(), 'height', includeMargin);
461
- }
462
-
463
- /**
464
- * Shows all elements.
465
- *
466
- * @param display - Optional display value (default: '')
467
- * @returns The instance for method chaining
468
- */
469
- show(display: string = ''): this {
470
- applyAll(this.elements, (el) => {
471
- el.removeAttribute('hidden');
472
- (el as HTMLElement).style.display = display;
473
- });
474
- return this;
475
- }
476
-
477
- /**
478
- * Hides all elements.
479
- *
480
- * @returns The instance for method chaining
481
- */
482
- hide(): this {
483
- applyAll(this.elements, (el) => {
484
- (el as HTMLElement).style.display = 'none';
485
- });
486
- return this;
487
- }
488
-
489
- /**
490
- * Adds an event listener to all elements.
491
- *
492
- * @param event - Event type
493
- * @param handler - Event handler
494
- * @returns The instance for method chaining
495
- */
496
- on(event: string, handler: EventListenerOrEventListenerObject): this {
497
- applyAll(this.elements, (el) => el.addEventListener(event, handler));
498
- return this;
499
- }
500
-
501
- /**
502
- * Adds a one-time event listener to all elements.
503
- *
504
- * @param event - Event type
505
- * @param handler - Event handler
506
- * @returns The instance for method chaining
507
- */
508
- once(event: string, handler: EventListener): this {
509
- applyAll(this.elements, (el) => el.addEventListener(event, handler, { once: true }));
510
- return this;
511
- }
512
-
513
- /**
514
- * Removes an event listener from all elements.
515
- *
516
- * @param event - Event type
517
- * @param handler - The handler to remove
518
- * @returns The instance for method chaining
519
- */
520
- off(event: string, handler: EventListenerOrEventListenerObject): this {
521
- applyAll(this.elements, (el) => el.removeEventListener(event, handler));
522
- return this;
523
- }
524
-
525
- /**
526
- * Triggers a custom event on all elements.
527
- *
528
- * @param event - Event type
529
- * @param detail - Optional event detail
530
- * @returns The instance for method chaining
531
- */
532
- trigger(event: string, detail?: unknown): this {
533
- applyAll(this.elements, (el) => {
534
- el.dispatchEvent(new CustomEvent(event, { detail, bubbles: true, cancelable: true }));
535
- });
536
- return this;
537
- }
538
-
539
- /**
540
- * Adds a delegated event listener to all elements.
541
- * Events are delegated to matching descendants.
542
- *
543
- * Use `undelegate()` to remove the listener later.
544
- *
545
- * @param event - Event type to listen for
546
- * @param selector - CSS selector to match against event targets
547
- * @param handler - Event handler function
548
- * @returns The instance for method chaining
549
- *
550
- * @example
551
- * ```ts
552
- * const handler = (e, target) => console.log('Clicked:', target.textContent);
553
- * $$('.container').delegate('click', '.item', handler);
554
- *
555
- * // Later, remove the delegated listener:
556
- * $$('.container').undelegate('click', '.item', handler);
557
- * ```
558
- */
559
- delegate(
560
- event: string,
561
- selector: string,
562
- handler: (event: Event, target: Element) => void
563
- ): this {
564
- const key = `${event}:${selector}`;
565
-
566
- applyAll(this.elements, (el) => {
567
- const wrapper: EventListener = (e: Event) => {
568
- const target = (e.target as Element).closest(selector);
569
- if (target && el.contains(target)) {
570
- handler(e, target);
571
- }
572
- };
573
-
574
- // Get or create the handler maps for this element
575
- if (!this.delegatedHandlers.has(el)) {
576
- this.delegatedHandlers.set(el, new Map());
577
- }
578
- const elementHandlers = this.delegatedHandlers.get(el)!;
579
-
580
- if (!elementHandlers.has(key)) {
581
- elementHandlers.set(key, new Map());
582
- }
583
- elementHandlers.get(key)!.set(handler, wrapper);
584
-
585
- el.addEventListener(event, wrapper);
586
- });
587
-
588
- return this;
589
- }
590
-
591
- /**
592
- * Removes a delegated event listener previously added with `delegate()`.
593
- *
594
- * @param event - Event type that was registered
595
- * @param selector - CSS selector that was used
596
- * @param handler - The original handler function passed to delegate()
597
- * @returns The instance for method chaining
598
- *
599
- * @example
600
- * ```ts
601
- * const handler = (e, target) => console.log('Clicked:', target.textContent);
602
- * $$('.container').delegate('click', '.item', handler);
603
- *
604
- * // Remove the delegated listener:
605
- * $$('.container').undelegate('click', '.item', handler);
606
- * ```
607
- */
608
- undelegate(
609
- event: string,
610
- selector: string,
611
- handler: (event: Event, target: Element) => void
612
- ): this {
613
- const key = `${event}:${selector}`;
614
-
615
- applyAll(this.elements, (el) => {
616
- const elementHandlers = this.delegatedHandlers.get(el);
617
- if (!elementHandlers) return;
618
-
619
- const handlers = elementHandlers.get(key);
620
- if (!handlers) return;
621
-
622
- const wrapper = handlers.get(handler);
623
- if (wrapper) {
624
- el.removeEventListener(event, wrapper);
625
- handlers.delete(handler);
626
-
627
- // Clean up empty maps
628
- if (handlers.size === 0) {
629
- elementHandlers.delete(key);
630
- }
631
- if (elementHandlers.size === 0) {
632
- this.delegatedHandlers.delete(el);
633
- }
634
- }
635
- });
636
-
637
- return this;
638
- }
639
-
640
- /**
641
- * Finds all descendant elements matching the selector across all elements
642
- * in the collection. Returns a new BQueryCollection with the results.
643
- *
644
- * @param selector - CSS selector to match
645
- * @returns A new BQueryCollection with all matching descendants
646
- *
647
- * @example
648
- * ```ts
649
- * $$('.container').find('.item').addClass('highlight');
650
- * ```
651
- */
652
- find(selector: string): BQueryCollection {
653
- const seen = new Set<Element>();
654
- const results: Element[] = [];
655
- for (const el of this.elements) {
656
- const found = el.querySelectorAll(selector);
657
- for (let i = 0; i < found.length; i++) {
658
- if (!seen.has(found[i])) {
659
- seen.add(found[i]);
660
- results.push(found[i]);
661
- }
662
- }
663
- }
664
- return new BQueryCollection(results);
665
- }
666
-
667
- /**
668
- * Removes all elements from the DOM.
669
- *
670
- * @returns The instance for method chaining
671
- */
672
- remove(): this {
673
- applyAll(this.elements, (el) => el.remove());
674
- return this;
675
- }
676
-
677
- /**
678
- * Clears all child nodes from all elements.
679
- *
680
- * @returns The instance for method chaining
681
- */
682
- empty(): this {
683
- applyAll(this.elements, (el) => {
684
- el.innerHTML = '';
685
- });
686
- return this;
687
- }
688
-
689
- /** @internal */
690
- private insertAll(content: InsertableContent, position: InsertPosition): void {
691
- if (typeof content === 'string') {
692
- // Sanitize once and reuse for all elements
693
- const sanitized = sanitizeContent(content);
694
- applyAll(this.elements, (el) => {
695
- el.insertAdjacentHTML(position, sanitized);
696
- });
697
- return;
698
- }
699
-
700
- const elements = toElementList(content);
701
- this.elements.forEach((el, index) => {
702
- const nodes =
703
- index === 0 ? elements : elements.map((node) => node.cloneNode(true) as Element);
704
- insertContent(el, nodes, position);
705
- });
706
- }
707
- }
1
+ import {
2
+ createElementFromHtml,
3
+ insertContent,
4
+ sanitizeContent,
5
+ type InsertableContent,
6
+ } from './dom';
7
+ import { BQueryElement } from './element';
8
+ import { applyAll, getInnerSize, getOuterSize, isHTMLElement, toElementList } from './shared';
9
+
10
+ /** Handler signature for delegated events */
11
+ type DelegatedHandler = (event: Event, target: Element) => void;
12
+
13
+ /**
14
+ * Wrapper for multiple DOM elements.
15
+ * Provides batch operations on a collection of elements with chainable API.
16
+ *
17
+ * This class enables jQuery-like operations across multiple elements:
18
+ * - All mutating methods apply to every element in the collection
19
+ * - Getter methods return data from the first element
20
+ * - Supports iteration via forEach, map, filter, and reduce
21
+ *
22
+ * @example
23
+ * ```ts
24
+ * $$('.items')
25
+ * .addClass('highlight')
26
+ * .css({ opacity: '0.8' })
27
+ * .on('click', () => console.log('clicked'));
28
+ * ```
29
+ */
30
+ export class BQueryCollection {
31
+ /**
32
+ * Stores delegated event handlers for cleanup via undelegate().
33
+ * Outer map: element -> (key -> (handler -> wrapper))
34
+ * Key format: `${event}:${selector}`
35
+ * @internal
36
+ */
37
+ private readonly delegatedHandlers = new WeakMap<
38
+ Element,
39
+ Map<string, Map<DelegatedHandler, EventListener>>
40
+ >();
41
+
42
+ /**
43
+ * Creates a new collection wrapper.
44
+ * @param elements - Array of DOM elements to wrap
45
+ */
46
+ constructor(public readonly elements: Element[]) {}
47
+
48
+ /**
49
+ * Gets the number of elements in the collection.
50
+ */
51
+ get length(): number {
52
+ return this.elements.length;
53
+ }
54
+
55
+ /**
56
+ * Gets the first element in the collection, if any.
57
+ * @internal
58
+ */
59
+ private first(): Element | undefined {
60
+ return this.elements[0];
61
+ }
62
+
63
+ /**
64
+ * Gets a single element as a BQueryElement wrapper.
65
+ *
66
+ * @param index - Zero-based index of the element
67
+ * @returns BQueryElement wrapper or undefined if out of range
68
+ */
69
+ eq(index: number): BQueryElement | undefined {
70
+ const el = this.elements[index];
71
+ return el ? new BQueryElement(el) : undefined;
72
+ }
73
+
74
+ /**
75
+ * Gets the first element as a BQueryElement wrapper.
76
+ *
77
+ * @returns BQueryElement wrapper or undefined if empty
78
+ */
79
+ firstEl(): BQueryElement | undefined {
80
+ return this.eq(0);
81
+ }
82
+
83
+ /**
84
+ * Gets the last element as a BQueryElement wrapper.
85
+ *
86
+ * @returns BQueryElement wrapper or undefined if empty
87
+ */
88
+ lastEl(): BQueryElement | undefined {
89
+ return this.eq(this.elements.length - 1);
90
+ }
91
+
92
+ /**
93
+ * Iterates over each element in the collection.
94
+ *
95
+ * @param callback - Function to call for each wrapped element
96
+ * @returns The instance for method chaining
97
+ */
98
+ each(callback: (element: BQueryElement, index: number) => void): this {
99
+ this.elements.forEach((element, index) => {
100
+ callback(new BQueryElement(element), index);
101
+ });
102
+ return this;
103
+ }
104
+
105
+ /**
106
+ * Maps each element to a new value.
107
+ *
108
+ * @param callback - Function to transform each element
109
+ * @returns Array of transformed values
110
+ */
111
+ map<T>(callback: (element: Element, index: number) => T): T[] {
112
+ return this.elements.map(callback);
113
+ }
114
+
115
+ /**
116
+ * Filters elements based on a predicate.
117
+ *
118
+ * @param predicate - Function to test each element
119
+ * @returns New BQueryCollection with matching elements
120
+ */
121
+ filter(predicate: (element: Element, index: number) => boolean): BQueryCollection {
122
+ return new BQueryCollection(this.elements.filter(predicate));
123
+ }
124
+
125
+ /**
126
+ * Reduces the collection to a single value.
127
+ *
128
+ * @param callback - Reducer function
129
+ * @param initialValue - Initial accumulator value
130
+ * @returns Accumulated result
131
+ */
132
+ reduce<T>(callback: (accumulator: T, element: Element, index: number) => T, initialValue: T): T {
133
+ return this.elements.reduce(callback, initialValue);
134
+ }
135
+
136
+ /**
137
+ * Converts the collection to an array of BQueryElement wrappers.
138
+ *
139
+ * @returns Array of BQueryElement instances
140
+ */
141
+ toArray(): BQueryElement[] {
142
+ return this.elements.map((el) => new BQueryElement(el));
143
+ }
144
+
145
+ /** Add one or more classes to all elements. */
146
+ addClass(...classNames: string[]): this {
147
+ applyAll(this.elements, (el) => el.classList.add(...classNames));
148
+ return this;
149
+ }
150
+
151
+ /** Remove one or more classes from all elements. */
152
+ removeClass(...classNames: string[]): this {
153
+ applyAll(this.elements, (el) => el.classList.remove(...classNames));
154
+ return this;
155
+ }
156
+
157
+ /** Toggle a class on all elements. */
158
+ toggleClass(className: string, force?: boolean): this {
159
+ applyAll(this.elements, (el) => el.classList.toggle(className, force));
160
+ return this;
161
+ }
162
+
163
+ /**
164
+ * Sets an attribute on all elements or gets from first.
165
+ *
166
+ * @param name - Attribute name
167
+ * @param value - Value to set (optional)
168
+ * @returns Attribute value when getting, instance when setting
169
+ */
170
+ attr(name: string, value?: string): string | this {
171
+ if (value === undefined) {
172
+ return this.first()?.getAttribute(name) ?? '';
173
+ }
174
+ applyAll(this.elements, (el) => el.setAttribute(name, value));
175
+ return this;
176
+ }
177
+
178
+ /**
179
+ * Removes an attribute from all elements.
180
+ *
181
+ * @param name - Attribute name to remove
182
+ * @returns The instance for method chaining
183
+ */
184
+ removeAttr(name: string): this {
185
+ applyAll(this.elements, (el) => el.removeAttribute(name));
186
+ return this;
187
+ }
188
+
189
+ /** Toggle an attribute on all elements. */
190
+ toggleAttr(name: string, force?: boolean): this {
191
+ applyAll(this.elements, (el) => {
192
+ const hasAttr = el.hasAttribute(name);
193
+ const shouldAdd = force ?? !hasAttr;
194
+ if (shouldAdd) {
195
+ el.setAttribute(name, '');
196
+ } else {
197
+ el.removeAttribute(name);
198
+ }
199
+ });
200
+ return this;
201
+ }
202
+
203
+ /**
204
+ * Sets text content on all elements or gets from first.
205
+ *
206
+ * @param value - Text to set (optional)
207
+ * @returns Text content when getting, instance when setting
208
+ */
209
+ text(value?: string): string | this {
210
+ if (value === undefined) {
211
+ return this.first()?.textContent ?? '';
212
+ }
213
+ applyAll(this.elements, (el) => {
214
+ el.textContent = value;
215
+ });
216
+ return this;
217
+ }
218
+
219
+ /**
220
+ * Sets sanitized HTML on all elements or gets from first.
221
+ *
222
+ * @param value - HTML to set (optional, will be sanitized)
223
+ * @returns HTML content when getting, instance when setting
224
+ */
225
+ html(value?: string): string | this {
226
+ if (value === undefined) {
227
+ return this.first()?.innerHTML ?? '';
228
+ }
229
+ const sanitized = sanitizeContent(value);
230
+ applyAll(this.elements, (el) => {
231
+ el.innerHTML = sanitized;
232
+ });
233
+ return this;
234
+ }
235
+
236
+ /**
237
+ * Sets HTML on all elements without sanitization.
238
+ *
239
+ * @param value - Raw HTML to set
240
+ * @returns The instance for method chaining
241
+ * @warning Bypasses XSS protection
242
+ */
243
+ htmlUnsafe(value: string): this {
244
+ applyAll(this.elements, (el) => {
245
+ el.innerHTML = value;
246
+ });
247
+ return this;
248
+ }
249
+
250
+ /** Append content to all elements. */
251
+ append(content: InsertableContent): this {
252
+ this.insertAll(content, 'beforeend');
253
+ return this;
254
+ }
255
+
256
+ /** Prepend content to all elements. */
257
+ prepend(content: InsertableContent): this {
258
+ this.insertAll(content, 'afterbegin');
259
+ return this;
260
+ }
261
+
262
+ /** Insert content before all elements. */
263
+ before(content: InsertableContent): this {
264
+ this.insertAll(content, 'beforebegin');
265
+ return this;
266
+ }
267
+
268
+ /** Insert content after all elements. */
269
+ after(content: InsertableContent): this {
270
+ this.insertAll(content, 'afterend');
271
+ return this;
272
+ }
273
+
274
+ /**
275
+ * Gets or sets CSS styles on all elements.
276
+ * When getting, returns the computed style value from the first element.
277
+ *
278
+ * @param property - Property name or object of properties
279
+ * @param value - Value when setting single property
280
+ * @returns The computed style value when getting, instance when setting
281
+ */
282
+ css(property: string): string;
283
+ css(property: string, value: string): this;
284
+ css(property: Record<string, string>): this;
285
+ css(property: string | Record<string, string>, value?: string): string | this {
286
+ if (typeof property === 'string') {
287
+ if (value !== undefined) {
288
+ applyAll(this.elements, (el) => {
289
+ (el as HTMLElement).style.setProperty(property, value);
290
+ });
291
+ return this;
292
+ }
293
+ const first = this.first();
294
+ if (!first) {
295
+ return '';
296
+ }
297
+ const view = first.ownerDocument?.defaultView;
298
+ if (!view || typeof view.getComputedStyle !== 'function') {
299
+ return '';
300
+ }
301
+ return view.getComputedStyle(first).getPropertyValue(property);
302
+ }
303
+
304
+ applyAll(this.elements, (el) => {
305
+ for (const [key, val] of Object.entries(property)) {
306
+ (el as HTMLElement).style.setProperty(key, val);
307
+ }
308
+ });
309
+ return this;
310
+ }
311
+
312
+ /** Wrap each element with a wrapper element or tag. */
313
+ wrap(wrapper: string | Element): this {
314
+ this.elements.forEach((el, index) => {
315
+ const wrapperEl =
316
+ typeof wrapper === 'string'
317
+ ? document.createElement(wrapper)
318
+ : index === 0
319
+ ? wrapper
320
+ : (wrapper.cloneNode(true) as Element);
321
+ el.parentNode?.insertBefore(wrapperEl, el);
322
+ wrapperEl.appendChild(el);
323
+ });
324
+ return this;
325
+ }
326
+
327
+ /**
328
+ * Remove the parent element of each element, keeping the elements in place.
329
+ *
330
+ * **Important**: This method unwraps ALL children of each parent element,
331
+ * not just the elements in the collection. If you call `unwrap()` on a
332
+ * collection containing only some children of a parent, all siblings will
333
+ * also be unwrapped. This behavior is consistent with jQuery's `.unwrap()`.
334
+ *
335
+ * @returns The collection for chaining
336
+ *
337
+ * @example
338
+ * ```ts
339
+ * // HTML: <div><section><span>A</span><span>B</span></section></div>
340
+ * const spans = $$('span');
341
+ * spans.unwrap(); // Removes <section>, both spans move to <div>
342
+ * // Result: <div><span>A</span><span>B</span></div>
343
+ * ```
344
+ */
345
+ unwrap(): this {
346
+ // Collect unique parent elements to avoid removing the same parent multiple times.
347
+ const parents = new Set<Element>();
348
+ for (const el of this.elements) {
349
+ if (el.parentElement) {
350
+ parents.add(el.parentElement);
351
+ }
352
+ }
353
+
354
+ // Unwrap each parent once: move all children out, then remove the wrapper.
355
+ parents.forEach((parent) => {
356
+ const grandParent = parent.parentNode;
357
+ if (!grandParent) return;
358
+
359
+ while (parent.firstChild) {
360
+ grandParent.insertBefore(parent.firstChild, parent);
361
+ }
362
+
363
+ parent.remove();
364
+ });
365
+ return this;
366
+ }
367
+
368
+ /** Replace each element with provided content. */
369
+ replaceWith(content: string | Element): BQueryCollection {
370
+ const replacements: Element[] = [];
371
+ this.elements.forEach((el, index) => {
372
+ const replacement =
373
+ typeof content === 'string'
374
+ ? createElementFromHtml(content)
375
+ : index === 0
376
+ ? content
377
+ : (content.cloneNode(true) as Element);
378
+ el.replaceWith(replacement);
379
+ replacements.push(replacement);
380
+ });
381
+ return new BQueryCollection(replacements);
382
+ }
383
+
384
+ /**
385
+ * Removes all elements from the DOM while keeping the wrapped nodes available
386
+ * for later reuse.
387
+ *
388
+ * @returns The instance for method chaining
389
+ */
390
+ detach(): this {
391
+ return this.remove();
392
+ }
393
+
394
+ /**
395
+ * Gets the zero-based sibling index of the first element in the collection.
396
+ *
397
+ * @returns Index of the first element, or -1 when unavailable
398
+ */
399
+ index(): number {
400
+ const first = this.first();
401
+ if (!first?.parentElement) {
402
+ return -1;
403
+ }
404
+ return Array.from(first.parentElement.children).indexOf(first);
405
+ }
406
+
407
+ /**
408
+ * Returns the child nodes of the first element, including text nodes and comments.
409
+ *
410
+ * @returns Array of child nodes from the first element
411
+ */
412
+ contents(): ChildNode[] {
413
+ return Array.from(this.first()?.childNodes ?? []);
414
+ }
415
+
416
+ /**
417
+ * Gets the offset parent of the first element in the collection.
418
+ *
419
+ * @returns Offset parent element, or null when unavailable
420
+ */
421
+ offsetParent(): Element | null {
422
+ const first = this.first();
423
+ return isHTMLElement(first) ? first.offsetParent : null;
424
+ }
425
+
426
+ /**
427
+ * Gets the position of the first element relative to its offset parent.
428
+ *
429
+ * @returns Position object with top and left coordinates
430
+ */
431
+ position(): { top: number; left: number } {
432
+ const first = this.first();
433
+ if (!isHTMLElement(first)) {
434
+ return { top: 0, left: 0 };
435
+ }
436
+
437
+ return {
438
+ top: first.offsetTop,
439
+ left: first.offsetLeft,
440
+ };
441
+ }
442
+
443
+ /**
444
+ * Gets the inner width of the first element (content + padding, excluding border).
445
+ *
446
+ * @returns Inner width in pixels, or 0 when the collection is empty
447
+ */
448
+ innerWidth(): number {
449
+ return getInnerSize(this.first(), 'width');
450
+ }
451
+
452
+ /**
453
+ * Gets the inner height of the first element (content + padding, excluding border).
454
+ *
455
+ * @returns Inner height in pixels, or 0 when the collection is empty
456
+ */
457
+ innerHeight(): number {
458
+ return getInnerSize(this.first(), 'height');
459
+ }
460
+
461
+ /**
462
+ * Gets the outer width of the first element, optionally including margins.
463
+ *
464
+ * @param includeMargin - When true, include horizontal margins
465
+ * @returns Outer width in pixels
466
+ */
467
+ outerWidth(includeMargin: boolean = false): number {
468
+ return getOuterSize(this.first(), 'width', includeMargin);
469
+ }
470
+
471
+ /**
472
+ * Gets the outer height of the first element, optionally including margins.
473
+ *
474
+ * @param includeMargin - When true, include vertical margins
475
+ * @returns Outer height in pixels
476
+ */
477
+ outerHeight(includeMargin: boolean = false): number {
478
+ return getOuterSize(this.first(), 'height', includeMargin);
479
+ }
480
+
481
+ /**
482
+ * Shows all elements.
483
+ *
484
+ * @param display - Optional display value (default: '')
485
+ * @returns The instance for method chaining
486
+ */
487
+ show(display: string = ''): this {
488
+ applyAll(this.elements, (el) => {
489
+ el.removeAttribute('hidden');
490
+ (el as HTMLElement).style.display = display;
491
+ });
492
+ return this;
493
+ }
494
+
495
+ /**
496
+ * Hides all elements.
497
+ *
498
+ * @returns The instance for method chaining
499
+ */
500
+ hide(): this {
501
+ applyAll(this.elements, (el) => {
502
+ (el as HTMLElement).style.display = 'none';
503
+ });
504
+ return this;
505
+ }
506
+
507
+ /**
508
+ * Adds an event listener to all elements.
509
+ *
510
+ * @param event - Event type
511
+ * @param handler - Event handler
512
+ * @returns The instance for method chaining
513
+ */
514
+ on(event: string, handler: EventListenerOrEventListenerObject): this {
515
+ applyAll(this.elements, (el) => el.addEventListener(event, handler));
516
+ return this;
517
+ }
518
+
519
+ /**
520
+ * Adds a one-time event listener to all elements.
521
+ *
522
+ * @param event - Event type
523
+ * @param handler - Event handler
524
+ * @returns The instance for method chaining
525
+ */
526
+ once(event: string, handler: EventListener): this {
527
+ applyAll(this.elements, (el) => el.addEventListener(event, handler, { once: true }));
528
+ return this;
529
+ }
530
+
531
+ /**
532
+ * Removes an event listener from all elements.
533
+ *
534
+ * @param event - Event type
535
+ * @param handler - The handler to remove
536
+ * @returns The instance for method chaining
537
+ */
538
+ off(event: string, handler: EventListenerOrEventListenerObject): this {
539
+ applyAll(this.elements, (el) => el.removeEventListener(event, handler));
540
+ return this;
541
+ }
542
+
543
+ /**
544
+ * Triggers a custom event on all elements.
545
+ *
546
+ * @param event - Event type
547
+ * @param detail - Optional event detail
548
+ * @returns The instance for method chaining
549
+ */
550
+ trigger(event: string, detail?: unknown): this {
551
+ applyAll(this.elements, (el) => {
552
+ el.dispatchEvent(new CustomEvent(event, { detail, bubbles: true, cancelable: true }));
553
+ });
554
+ return this;
555
+ }
556
+
557
+ /**
558
+ * Adds a delegated event listener to all elements.
559
+ * Events are delegated to matching descendants.
560
+ *
561
+ * Use `undelegate()` to remove the listener later.
562
+ *
563
+ * @param event - Event type to listen for
564
+ * @param selector - CSS selector to match against event targets
565
+ * @param handler - Event handler function
566
+ * @returns The instance for method chaining
567
+ *
568
+ * @example
569
+ * ```ts
570
+ * const handler = (e, target) => console.log('Clicked:', target.textContent);
571
+ * $$('.container').delegate('click', '.item', handler);
572
+ *
573
+ * // Later, remove the delegated listener:
574
+ * $$('.container').undelegate('click', '.item', handler);
575
+ * ```
576
+ */
577
+ delegate(
578
+ event: string,
579
+ selector: string,
580
+ handler: (event: Event, target: Element) => void
581
+ ): this {
582
+ const key = `${event}:${selector}`;
583
+
584
+ applyAll(this.elements, (el) => {
585
+ const wrapper: EventListener = (e: Event) => {
586
+ const target = (e.target as Element).closest(selector);
587
+ if (target && el.contains(target)) {
588
+ handler(e, target);
589
+ }
590
+ };
591
+
592
+ // Get or create the handler maps for this element
593
+ if (!this.delegatedHandlers.has(el)) {
594
+ this.delegatedHandlers.set(el, new Map());
595
+ }
596
+ const elementHandlers = this.delegatedHandlers.get(el)!;
597
+
598
+ if (!elementHandlers.has(key)) {
599
+ elementHandlers.set(key, new Map());
600
+ }
601
+ elementHandlers.get(key)!.set(handler, wrapper);
602
+
603
+ el.addEventListener(event, wrapper);
604
+ });
605
+
606
+ return this;
607
+ }
608
+
609
+ /**
610
+ * Removes a delegated event listener previously added with `delegate()`.
611
+ *
612
+ * @param event - Event type that was registered
613
+ * @param selector - CSS selector that was used
614
+ * @param handler - The original handler function passed to delegate()
615
+ * @returns The instance for method chaining
616
+ *
617
+ * @example
618
+ * ```ts
619
+ * const handler = (e, target) => console.log('Clicked:', target.textContent);
620
+ * $$('.container').delegate('click', '.item', handler);
621
+ *
622
+ * // Remove the delegated listener:
623
+ * $$('.container').undelegate('click', '.item', handler);
624
+ * ```
625
+ */
626
+ undelegate(
627
+ event: string,
628
+ selector: string,
629
+ handler: (event: Event, target: Element) => void
630
+ ): this {
631
+ const key = `${event}:${selector}`;
632
+
633
+ applyAll(this.elements, (el) => {
634
+ const elementHandlers = this.delegatedHandlers.get(el);
635
+ if (!elementHandlers) return;
636
+
637
+ const handlers = elementHandlers.get(key);
638
+ if (!handlers) return;
639
+
640
+ const wrapper = handlers.get(handler);
641
+ if (wrapper) {
642
+ el.removeEventListener(event, wrapper);
643
+ handlers.delete(handler);
644
+
645
+ // Clean up empty maps
646
+ if (handlers.size === 0) {
647
+ elementHandlers.delete(key);
648
+ }
649
+ if (elementHandlers.size === 0) {
650
+ this.delegatedHandlers.delete(el);
651
+ }
652
+ }
653
+ });
654
+
655
+ return this;
656
+ }
657
+
658
+ /**
659
+ * Finds all descendant elements matching the selector across all elements
660
+ * in the collection. Returns a new BQueryCollection with the results.
661
+ *
662
+ * @param selector - CSS selector to match
663
+ * @returns A new BQueryCollection with all matching descendants
664
+ *
665
+ * @example
666
+ * ```ts
667
+ * $$('.container').find('.item').addClass('highlight');
668
+ * ```
669
+ */
670
+ find(selector: string): BQueryCollection {
671
+ const seen = new Set<Element>();
672
+ const results: Element[] = [];
673
+ for (const el of this.elements) {
674
+ const found = el.querySelectorAll(selector);
675
+ for (let i = 0; i < found.length; i++) {
676
+ if (!seen.has(found[i])) {
677
+ seen.add(found[i]);
678
+ results.push(found[i]);
679
+ }
680
+ }
681
+ }
682
+ return new BQueryCollection(results);
683
+ }
684
+
685
+ /**
686
+ * Gets the closest element or ancestor matching a selector for each element in
687
+ * the collection, including the element itself. Duplicates are removed from the
688
+ * result.
689
+ *
690
+ * @param selector - CSS selector to match
691
+ * @returns A new BQueryCollection with matching elements or ancestors
692
+ *
693
+ * @example
694
+ * ```ts
695
+ * $$('.item').closest('.container');
696
+ * ```
697
+ */
698
+ closest(selector: string): BQueryCollection {
699
+ const seen = new Set<Element>();
700
+ const results: Element[] = [];
701
+ for (const el of this.elements) {
702
+ const match = el.closest(selector);
703
+ if (match && !seen.has(match)) {
704
+ seen.add(match);
705
+ results.push(match);
706
+ }
707
+ }
708
+ return new BQueryCollection(results);
709
+ }
710
+
711
+ /**
712
+ * Gets the parent element of each element in the collection.
713
+ * Duplicates are removed (e.g. siblings sharing a parent).
714
+ *
715
+ * @returns A new BQueryCollection with unique parent elements
716
+ *
717
+ * @example
718
+ * ```ts
719
+ * $$('.item').parent().addClass('has-items');
720
+ * ```
721
+ */
722
+ parent(): BQueryCollection {
723
+ const seen = new Set<Element>();
724
+ const results: Element[] = [];
725
+ for (const el of this.elements) {
726
+ const p = el.parentElement;
727
+ if (p && !seen.has(p)) {
728
+ seen.add(p);
729
+ results.push(p);
730
+ }
731
+ }
732
+ return new BQueryCollection(results);
733
+ }
734
+
735
+ /**
736
+ * Gets the direct children of every element in the collection.
737
+ * Duplicates are removed from the result.
738
+ *
739
+ * @returns A new BQueryCollection with child elements
740
+ *
741
+ * @example
742
+ * ```ts
743
+ * $$('.list').children().addClass('child');
744
+ * ```
745
+ */
746
+ children(): BQueryCollection {
747
+ const seen = new Set<Element>();
748
+ const results: Element[] = [];
749
+ for (const el of this.elements) {
750
+ for (const child of Array.from(el.children)) {
751
+ if (!seen.has(child)) {
752
+ seen.add(child);
753
+ results.push(child);
754
+ }
755
+ }
756
+ }
757
+ return new BQueryCollection(results);
758
+ }
759
+
760
+ /**
761
+ * Gets all siblings of every element in the collection (excluding the
762
+ * elements themselves). Duplicates are removed.
763
+ *
764
+ * @returns A new BQueryCollection with sibling elements
765
+ *
766
+ * @example
767
+ * ```ts
768
+ * $$('.active').siblings().removeClass('active');
769
+ * ```
770
+ */
771
+ siblings(): BQueryCollection {
772
+ const selfSet = new Set(this.elements);
773
+ const seen = new Set<Element>();
774
+ const results: Element[] = [];
775
+ for (const el of this.elements) {
776
+ const parent = el.parentElement;
777
+ if (!parent) continue;
778
+ for (const sibling of Array.from(parent.children)) {
779
+ if (!selfSet.has(sibling) && !seen.has(sibling)) {
780
+ seen.add(sibling);
781
+ results.push(sibling);
782
+ }
783
+ }
784
+ }
785
+ return new BQueryCollection(results);
786
+ }
787
+
788
+ /**
789
+ * Gets the next sibling element of each element in the collection.
790
+ * Elements without a next sibling are skipped.
791
+ *
792
+ * @returns A new BQueryCollection with next sibling elements
793
+ *
794
+ * @example
795
+ * ```ts
796
+ * $$('.current').next().addClass('upcoming');
797
+ * ```
798
+ */
799
+ next(): BQueryCollection {
800
+ const seen = new Set<Element>();
801
+ const results: Element[] = [];
802
+ for (const el of this.elements) {
803
+ const n = el.nextElementSibling;
804
+ if (n && !seen.has(n)) {
805
+ seen.add(n);
806
+ results.push(n);
807
+ }
808
+ }
809
+ return new BQueryCollection(results);
810
+ }
811
+
812
+ /**
813
+ * Gets the previous sibling element of each element in the collection.
814
+ * Elements without a previous sibling are skipped.
815
+ *
816
+ * @returns A new BQueryCollection with previous sibling elements
817
+ *
818
+ * @example
819
+ * ```ts
820
+ * $$('.current').prev().addClass('previous');
821
+ * ```
822
+ */
823
+ prev(): BQueryCollection {
824
+ const seen = new Set<Element>();
825
+ const results: Element[] = [];
826
+ for (const el of this.elements) {
827
+ const p = el.previousElementSibling;
828
+ if (p && !seen.has(p)) {
829
+ seen.add(p);
830
+ results.push(p);
831
+ }
832
+ }
833
+ return new BQueryCollection(results);
834
+ }
835
+
836
+ /**
837
+ * Removes all elements from the DOM.
838
+ *
839
+ * @returns The instance for method chaining
840
+ */
841
+ remove(): this {
842
+ applyAll(this.elements, (el) => el.remove());
843
+ return this;
844
+ }
845
+
846
+ /**
847
+ * Clears all child nodes from all elements.
848
+ *
849
+ * @returns The instance for method chaining
850
+ */
851
+ empty(): this {
852
+ applyAll(this.elements, (el) => {
853
+ el.innerHTML = '';
854
+ });
855
+ return this;
856
+ }
857
+
858
+ /** @internal */
859
+ private insertAll(content: InsertableContent, position: InsertPosition): void {
860
+ if (typeof content === 'string') {
861
+ // Sanitize once and reuse for all elements
862
+ const sanitized = sanitizeContent(content);
863
+ applyAll(this.elements, (el) => {
864
+ el.insertAdjacentHTML(position, sanitized);
865
+ });
866
+ return;
867
+ }
868
+
869
+ const elements = toElementList(content);
870
+ this.elements.forEach((el, index) => {
871
+ const nodes =
872
+ index === 0 ? elements : elements.map((node) => node.cloneNode(true) as Element);
873
+ insertContent(el, nodes, position);
874
+ });
875
+ }
876
+ }