accented 0.0.1-dev.4 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. package/NOTICE +14 -0
  2. package/README.md +71 -0
  3. package/dist/accented.d.ts +28 -7
  4. package/dist/accented.d.ts.map +1 -1
  5. package/dist/accented.js +107 -42
  6. package/dist/accented.js.map +1 -1
  7. package/dist/common/tokens.d.ts +7 -0
  8. package/dist/common/tokens.d.ts.map +1 -0
  9. package/dist/common/tokens.js +8 -0
  10. package/dist/common/tokens.js.map +1 -0
  11. package/dist/constants.d.ts +4 -0
  12. package/dist/constants.d.ts.map +1 -0
  13. package/dist/constants.js +4 -0
  14. package/dist/constants.js.map +1 -0
  15. package/dist/dom-updater.d.ts +1 -6
  16. package/dist/dom-updater.d.ts.map +1 -1
  17. package/dist/dom-updater.js +136 -20
  18. package/dist/dom-updater.js.map +1 -1
  19. package/dist/elements/accented-dialog.d.ts +359 -0
  20. package/dist/elements/accented-dialog.d.ts.map +1 -0
  21. package/dist/elements/accented-dialog.js +377 -0
  22. package/dist/elements/accented-dialog.js.map +1 -0
  23. package/dist/elements/accented-trigger.d.ts +364 -0
  24. package/dist/elements/accented-trigger.d.ts.map +1 -0
  25. package/dist/elements/accented-trigger.js +214 -0
  26. package/dist/elements/accented-trigger.js.map +1 -0
  27. package/dist/fullscreen-listener.d.ts +2 -0
  28. package/dist/fullscreen-listener.d.ts.map +1 -0
  29. package/dist/fullscreen-listener.js +17 -0
  30. package/dist/fullscreen-listener.js.map +1 -0
  31. package/dist/intersection-observer.d.ts +5 -0
  32. package/dist/intersection-observer.d.ts.map +1 -0
  33. package/dist/intersection-observer.js +34 -0
  34. package/dist/intersection-observer.js.map +1 -0
  35. package/dist/log-and-rethrow.d.ts +2 -0
  36. package/dist/log-and-rethrow.d.ts.map +1 -0
  37. package/dist/log-and-rethrow.js +6 -0
  38. package/dist/log-and-rethrow.js.map +1 -0
  39. package/dist/logger.d.ts +2 -0
  40. package/dist/logger.d.ts.map +1 -0
  41. package/dist/logger.js +28 -0
  42. package/dist/logger.js.map +1 -0
  43. package/dist/register-elements.d.ts +2 -0
  44. package/dist/register-elements.d.ts.map +1 -0
  45. package/dist/register-elements.js +20 -0
  46. package/dist/register-elements.js.map +1 -0
  47. package/dist/resize-listener.d.ts +2 -0
  48. package/dist/resize-listener.d.ts.map +1 -0
  49. package/dist/resize-listener.js +17 -0
  50. package/dist/resize-listener.js.map +1 -0
  51. package/dist/scanner.d.ts +3 -0
  52. package/dist/scanner.d.ts.map +1 -0
  53. package/dist/scanner.js +153 -0
  54. package/dist/scanner.js.map +1 -0
  55. package/dist/scroll-listeners.d.ts +2 -0
  56. package/dist/scroll-listeners.d.ts.map +1 -0
  57. package/dist/scroll-listeners.js +37 -0
  58. package/dist/scroll-listeners.js.map +1 -0
  59. package/dist/state.d.ts +7 -0
  60. package/dist/state.d.ts.map +1 -0
  61. package/dist/state.js +16 -0
  62. package/dist/state.js.map +1 -0
  63. package/dist/task-queue.d.ts +5 -6
  64. package/dist/task-queue.d.ts.map +1 -1
  65. package/dist/task-queue.js +30 -25
  66. package/dist/task-queue.js.map +1 -1
  67. package/dist/types.d.ts +227 -0
  68. package/dist/types.d.ts.map +1 -0
  69. package/dist/types.js +2 -0
  70. package/dist/types.js.map +1 -0
  71. package/dist/utils/are-elements-with-issues-equal.d.ts +3 -0
  72. package/dist/utils/are-elements-with-issues-equal.d.ts.map +1 -0
  73. package/dist/utils/are-elements-with-issues-equal.js +5 -0
  74. package/dist/utils/are-elements-with-issues-equal.js.map +1 -0
  75. package/dist/utils/are-issue-sets-equal.d.ts +3 -0
  76. package/dist/utils/are-issue-sets-equal.d.ts.map +1 -0
  77. package/dist/utils/are-issue-sets-equal.js +6 -0
  78. package/dist/utils/are-issue-sets-equal.js.map +1 -0
  79. package/dist/utils/containing-blocks.d.ts +3 -0
  80. package/dist/utils/containing-blocks.d.ts.map +1 -0
  81. package/dist/utils/containing-blocks.js +46 -0
  82. package/dist/utils/containing-blocks.js.map +1 -0
  83. package/dist/utils/contains.d.ts +2 -0
  84. package/dist/utils/contains.d.ts.map +1 -0
  85. package/dist/utils/contains.js +19 -0
  86. package/dist/utils/contains.js.map +1 -0
  87. package/dist/utils/deduplicate-nodes.d.ts +2 -0
  88. package/dist/utils/deduplicate-nodes.d.ts.map +1 -0
  89. package/dist/utils/deduplicate-nodes.js +4 -0
  90. package/dist/utils/deduplicate-nodes.js.map +1 -0
  91. package/dist/utils/deep-merge.d.ts +4 -0
  92. package/dist/utils/deep-merge.d.ts.map +1 -0
  93. package/dist/utils/deep-merge.js +21 -0
  94. package/dist/utils/deep-merge.js.map +1 -0
  95. package/dist/utils/dom-helpers.d.ts +9 -0
  96. package/dist/utils/dom-helpers.d.ts.map +1 -0
  97. package/dist/utils/dom-helpers.js +34 -0
  98. package/dist/utils/dom-helpers.js.map +1 -0
  99. package/dist/utils/ensure-non-empty.d.ts +2 -0
  100. package/dist/utils/ensure-non-empty.d.ts.map +1 -0
  101. package/dist/utils/ensure-non-empty.js +7 -0
  102. package/dist/utils/ensure-non-empty.js.map +1 -0
  103. package/dist/utils/get-element-html.d.ts +2 -0
  104. package/dist/utils/get-element-html.d.ts.map +1 -0
  105. package/dist/utils/get-element-html.js +16 -0
  106. package/dist/utils/get-element-html.js.map +1 -0
  107. package/dist/utils/get-element-position.d.ts +11 -0
  108. package/dist/utils/get-element-position.d.ts.map +1 -0
  109. package/dist/utils/get-element-position.js +70 -0
  110. package/dist/utils/get-element-position.js.map +1 -0
  111. package/dist/utils/get-parent.d.ts +2 -0
  112. package/dist/utils/get-parent.d.ts.map +1 -0
  113. package/dist/utils/get-parent.js +12 -0
  114. package/dist/utils/get-parent.js.map +1 -0
  115. package/dist/utils/get-scan-context.d.ts +3 -0
  116. package/dist/utils/get-scan-context.d.ts.map +1 -0
  117. package/dist/utils/get-scan-context.js +28 -0
  118. package/dist/utils/get-scan-context.js.map +1 -0
  119. package/dist/utils/get-scrollable-ancestors.d.ts +2 -0
  120. package/dist/utils/get-scrollable-ancestors.d.ts.map +1 -0
  121. package/dist/utils/get-scrollable-ancestors.js +19 -0
  122. package/dist/utils/get-scrollable-ancestors.js.map +1 -0
  123. package/dist/utils/is-node-in-scan-context.d.ts +3 -0
  124. package/dist/utils/is-node-in-scan-context.d.ts.map +1 -0
  125. package/dist/utils/is-node-in-scan-context.js +26 -0
  126. package/dist/utils/is-node-in-scan-context.js.map +1 -0
  127. package/dist/utils/is-non-empty.d.ts +2 -0
  128. package/dist/utils/is-non-empty.d.ts.map +1 -0
  129. package/dist/utils/is-non-empty.js +4 -0
  130. package/dist/utils/is-non-empty.js.map +1 -0
  131. package/dist/utils/normalize-context.d.ts +3 -0
  132. package/dist/utils/normalize-context.d.ts.map +1 -0
  133. package/dist/utils/normalize-context.js +59 -0
  134. package/dist/utils/normalize-context.js.map +1 -0
  135. package/dist/utils/recalculate-positions.d.ts +2 -0
  136. package/dist/utils/recalculate-positions.d.ts.map +1 -0
  137. package/dist/utils/recalculate-positions.js +27 -0
  138. package/dist/utils/recalculate-positions.js.map +1 -0
  139. package/dist/utils/recalculate-scrollable-ancestors.d.ts +2 -0
  140. package/dist/utils/recalculate-scrollable-ancestors.d.ts.map +1 -0
  141. package/dist/utils/recalculate-scrollable-ancestors.js +13 -0
  142. package/dist/utils/recalculate-scrollable-ancestors.js.map +1 -0
  143. package/dist/utils/shadow-dom-aware-mutation-observer.d.ts +10 -0
  144. package/dist/utils/shadow-dom-aware-mutation-observer.d.ts.map +1 -0
  145. package/dist/utils/shadow-dom-aware-mutation-observer.js +61 -0
  146. package/dist/utils/shadow-dom-aware-mutation-observer.js.map +1 -0
  147. package/dist/utils/supports-anchor-positioning.d.ts +6 -0
  148. package/dist/utils/supports-anchor-positioning.d.ts.map +1 -0
  149. package/dist/utils/supports-anchor-positioning.js +4 -0
  150. package/dist/utils/supports-anchor-positioning.js.map +1 -0
  151. package/dist/utils/transform-violations.d.ts +4 -0
  152. package/dist/utils/transform-violations.d.ts.map +1 -0
  153. package/dist/utils/transform-violations.js +61 -0
  154. package/dist/utils/transform-violations.js.map +1 -0
  155. package/dist/utils/update-elements-with-issues.d.ts +13 -0
  156. package/dist/utils/update-elements-with-issues.d.ts.map +1 -0
  157. package/dist/utils/update-elements-with-issues.js +96 -0
  158. package/dist/utils/update-elements-with-issues.js.map +1 -0
  159. package/dist/validate-options.d.ts +3 -0
  160. package/dist/validate-options.d.ts.map +1 -0
  161. package/dist/validate-options.js +129 -0
  162. package/dist/validate-options.js.map +1 -0
  163. package/package.json +21 -8
  164. package/src/accented.test.ts +24 -0
  165. package/src/accented.ts +130 -0
  166. package/src/common/tokens.ts +10 -0
  167. package/src/constants.ts +3 -0
  168. package/src/dom-updater.ts +165 -0
  169. package/src/elements/accented-dialog.ts +419 -0
  170. package/src/elements/accented-trigger.ts +251 -0
  171. package/src/fullscreen-listener.ts +21 -0
  172. package/src/intersection-observer.ts +39 -0
  173. package/src/log-and-rethrow.ts +8 -0
  174. package/src/logger.ts +36 -0
  175. package/src/register-elements.ts +21 -0
  176. package/src/resize-listener.ts +21 -0
  177. package/src/scanner.ts +195 -0
  178. package/src/scroll-listeners.ts +45 -0
  179. package/src/state.ts +35 -0
  180. package/src/task-queue.test.ts +136 -0
  181. package/src/task-queue.ts +61 -0
  182. package/src/types.ts +258 -0
  183. package/src/utils/are-elements-with-issues-equal.ts +11 -0
  184. package/src/utils/are-issue-sets-equal.test.ts +53 -0
  185. package/src/utils/are-issue-sets-equal.ts +12 -0
  186. package/src/utils/containing-blocks.ts +60 -0
  187. package/src/utils/contains.test.ts +54 -0
  188. package/src/utils/contains.ts +19 -0
  189. package/src/utils/deduplicate-nodes.ts +3 -0
  190. package/src/utils/deep-merge.test.ts +41 -0
  191. package/src/utils/deep-merge.ts +24 -0
  192. package/src/utils/dom-helpers.ts +42 -0
  193. package/src/utils/ensure-non-empty.ts +6 -0
  194. package/src/utils/get-element-html.ts +15 -0
  195. package/src/utils/get-element-position.ts +89 -0
  196. package/src/utils/get-parent.ts +14 -0
  197. package/src/utils/get-scan-context.test.ts +85 -0
  198. package/src/utils/get-scan-context.ts +36 -0
  199. package/src/utils/get-scrollable-ancestors.ts +22 -0
  200. package/src/utils/is-node-in-scan-context.test.ts +70 -0
  201. package/src/utils/is-node-in-scan-context.ts +29 -0
  202. package/src/utils/is-non-empty.ts +3 -0
  203. package/src/utils/normalize-context.test.ts +105 -0
  204. package/src/utils/normalize-context.ts +65 -0
  205. package/src/utils/recalculate-positions.ts +27 -0
  206. package/src/utils/recalculate-scrollable-ancestors.ts +13 -0
  207. package/src/utils/shadow-dom-aware-mutation-observer.ts +75 -0
  208. package/src/utils/supports-anchor-positioning.ts +7 -0
  209. package/src/utils/transform-violations.test.ts +128 -0
  210. package/src/utils/transform-violations.ts +74 -0
  211. package/src/utils/update-elements-with-issues.test.ts +371 -0
  212. package/src/utils/update-elements-with-issues.ts +144 -0
  213. package/src/validate-options.ts +184 -0
  214. package/dist/utils/issuesToElements.d.ts +0 -3
  215. package/dist/utils/issuesToElements.d.ts.map +0 -1
  216. package/dist/utils/issuesToElements.js +0 -16
  217. package/dist/utils/issuesToElements.js.map +0 -1
package/src/types.ts ADDED
@@ -0,0 +1,258 @@
1
+ import type { Signal } from '@preact/signals-core';
2
+ import type axe from 'axe-core';
3
+ import type { AccentedTrigger } from './elements/accented-trigger.ts';
4
+
5
+ export type Throttle = {
6
+ /**
7
+ * How long Accented must wait (in milliseconds) to run a scan after a mutation or after the previous scan (whichever finished last).
8
+ *
9
+ * If the page you’re scanning has a lot of nodes,
10
+ * scanning may take a noticeable time (~ a few hundred milliseconds),
11
+ * during which time the main thread will be blocked most of the time.
12
+ *
13
+ * You may want to experiment with this value if your page contents change frequently
14
+ * or if it has JavaScript-based animations running on the main thread.
15
+ *
16
+ * @default 1000
17
+ * */
18
+ wait?: number;
19
+
20
+ /**
21
+ * If `leading` is set to `true`, the scan runs immediately after a mutation.
22
+ * In this case, `wait` only applies to subsequent scans,
23
+ * giving the page at least `wait` milliseconds between the end of the previous scan
24
+ * and the beginning of the next one.
25
+ *
26
+ * If `leading` is set to `false`, the wait applies to mutations as well,
27
+ * delaying the output.
28
+ * This may be useful if you’re expecting quick bursts of mutations on your page.
29
+ *
30
+ * @default true
31
+ * */
32
+ leading?: boolean;
33
+ };
34
+
35
+ export type Output = {
36
+ /**
37
+ * Whether the list of elements with issues should be printed to the browser console whenever issues are added, removed, or changed.
38
+ *
39
+ * @default true
40
+ * */
41
+ console?: boolean;
42
+ };
43
+
44
+ /**
45
+ * Model context type based on axe.ElementContext,
46
+ * excluding frame selectors (since we don't support scanning iframes).
47
+ */
48
+
49
+ export type Selector = Exclude<axe.Selector, axe.LabelledFramesSelector>;
50
+
51
+ // axe.SelectorList also can have FrameSelector elements in the array.
52
+ // We're not allowing that.
53
+ export type SelectorList = Array<Selector> | NodeList;
54
+
55
+ // The rest of the type is structured the same as in axe-core.
56
+ export type ContextProp = Selector | SelectorList;
57
+
58
+ export type ContextObject =
59
+ | {
60
+ include: ContextProp;
61
+ exclude?: ContextProp;
62
+ }
63
+ | {
64
+ exclude: ContextProp;
65
+ include?: ContextProp;
66
+ };
67
+
68
+ export type Context = ContextProp | ContextObject;
69
+
70
+ export const allowedAxeOptions = ['rules', 'runOnly'] as const;
71
+
72
+ export type AxeOptions = Pick<axe.RunOptions, (typeof allowedAxeOptions)[number]>;
73
+
74
+ type CallbackParams = {
75
+ /**
76
+ * The most up-to-date array of all elements with accessibility issues.
77
+ * */
78
+ elementsWithIssues: Array<ElementWithIssues>;
79
+
80
+ /**
81
+ * Runtime performance of the last scan. An object with the following props:
82
+ * - `totalBlockingTime`: how long the main thread was blocked by Accented during the last scan, in milliseconds.
83
+ * It’s further divided into the `scan` and `domUpdate` phases.
84
+ * - `scan`: how long scanning (the execution of `axe.run()`) took, in milliseconds.
85
+ * - `domUpdate`: how long the DOM update (adding / removing outlines and dialog trigger buttons) took, in milliseconds.
86
+ * */
87
+ performance: {
88
+ totalBlockingTime: number;
89
+ scan: number;
90
+ domUpdate: number;
91
+ };
92
+
93
+ /**
94
+ * Nodes that got scanned. Either an array of nodes,
95
+ * or an object with `include` and `exclude` properties (if any nodes were excluded).
96
+ */
97
+ scanContext: ScanContext | Array<Node>;
98
+ };
99
+
100
+ export type Callback = (params: CallbackParams) => void;
101
+
102
+ export type AccentedOptions = {
103
+ /**
104
+ * The `options` parameter for `axe.run()`.
105
+ *
106
+ * Accented only supports two keys of the `options` object:
107
+ * * `rules`;
108
+ * * `runOnly`.
109
+ *
110
+ * Both properties are optional, and both control
111
+ * which accessibility rules your page is tested against.
112
+ *
113
+ * See documentation: https://www.deque.com/axe/core-documentation/api-documentation/#options-parameter
114
+ *
115
+ * @default {}
116
+ */
117
+ axeOptions?: AxeOptions;
118
+
119
+ /**
120
+ * A function that will be called after each scan.
121
+ *
122
+ * Potential uses:
123
+
124
+ * - do something with the scan results,
125
+ * for example send them to a backend for storage and analysis;
126
+ * - analyze Accented’s performance.
127
+ *
128
+ * @default () => {}
129
+ *
130
+ * @example
131
+ *
132
+ * accented({
133
+ * callback: ({ elementsWithIssues, performance, scanContext }) => {
134
+ * console.log('Elements with issues:', elementsWithIssues);
135
+ * console.log('Total blocking time:', performance.totalBlockingTime);
136
+ * console.log('Scan context:', scanContext);
137
+ * }
138
+ * });
139
+ *
140
+ * */
141
+ callback?: Callback;
142
+
143
+ /**
144
+ * The `context` parameter for `axe.run()`.
145
+ *
146
+ * Determines what part(s) of the page to scan for accessibility issues.
147
+ *
148
+ * Accepts a variety of shapes:
149
+ *
150
+ * - a [`Node`](https://developer.mozilla.org/en-US/docs/Web/API/Node) (in practice it will likely be an instance of [`Element`](https://developer.mozilla.org/en-US/docs/Web/API/Element), [`Document`](https://developer.mozilla.org/en-US/docs/Web/API/Document), or [`DocumentFragment`](https://developer.mozilla.org/en-US/docs/Web/API/DocumentFragment));
151
+ * - a valid [CSS selector](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_selectors);
152
+ * - an object for selecting elements within shadow DOM,
153
+ * whose shape is `{ fromShadowDom: [selector1, selector2, ...] }`,
154
+ * where `selector1`, `selector2`, etc. select shadow hosts, and the last selector selects the actual context.
155
+ * `selector2` in this example is _within_ the shadow root created on the element(s) that match `selector1`,
156
+ * so in practice you shouldn’t have more than two elements in such an array
157
+ * unless you have a very complex structure with multiple shadow DOM layers;
158
+ * - a [`NodeList`](https://developer.mozilla.org/en-US/docs/Web/API/NodeList) (likely a result of a [`querySelectorAll()`](https://developer.mozilla.org/en-US/docs/Web/API/Document/querySelectorAll) call);
159
+ * - an array containing any combination of selectors, nodes, or shadow DOM objects (described above);
160
+ * - an object containing `include` and / or `exclude` properties.
161
+ * It’s useful if you’d like to exclude certain elements or parts of the page.
162
+ * The values for `include` and `exclude` can take any of the above shapes.
163
+ * It’s unlikely that you’d want to have complex `include` / `exclude` rules,
164
+ * but if you do, the exact behavior is documented by the relevant tests:
165
+ * [`is-node-in-scan-context.test.ts`](https://github.com/pomerantsev/accented/blob/main/packages/accented/src/utils/is-node-in-scan-context.test.ts).
166
+ *
167
+ * See also the documentation for the [`context` parameter of `axe.run()`](https://www.deque.com/axe/core-documentation/api-documentation/#context-parameter),
168
+ * which the `context` option from Accented mostly mirrors
169
+ * (note that Accented doesn’t support the `fromFrames` object shape).
170
+ *
171
+ * @default document
172
+ */
173
+ context?: Context;
174
+
175
+ /**
176
+ * The character sequence that’s used in various elements, attributes and stylesheets that Accented adds to the page.
177
+ *
178
+ * You shouldn’t have to provide this prop unless some of the names on your page have "accented" in it and conflict with what Accented provides by default.
179
+ *
180
+ * * The data attribute that’s added to elements with issues (default: `data-accented`).
181
+ * * The names of custom elements for the button and the dialog that get created for each element with issues
182
+ * (default: `accented-trigger`, `accented-dialog`).
183
+ * * The CSS cascade layer containing page-wide Accented-specific styles (default: `accented`).
184
+ * * The prefix for some of the CSS custom properties used by Accented (default: `--accented-`).
185
+ * * The window property that’s used to prevent multiple axe-core scans from running simultaneously
186
+ * (default: `__accented_axe_running__`).
187
+ *
188
+ * Only lowercase alphanumeric characters and dashes (-) are allowed in the name,
189
+ * and it must start with a lowercase letter.
190
+ *
191
+ * @default 'accented'
192
+ *
193
+ * @example
194
+ *
195
+ * accented({name: 'my-name'});
196
+ *
197
+ * With the above option provided, the attribute set on elements with issues will be `data-my-name`,
198
+ * a custom element will be called `my-name-trigger`, and so on.
199
+ *
200
+ */
201
+ name?: string;
202
+
203
+ /**
204
+ * An object controlling how the results of scans are presented.
205
+ * */
206
+ output?: Output;
207
+
208
+ /**
209
+ * An object controlling at what moments Accented will run its scans.
210
+ * */
211
+ throttle?: Throttle;
212
+ };
213
+
214
+ /**
215
+ * A function that fully disables Accented,
216
+ * stopping the scanning and removing all highlights from the page.
217
+ */
218
+ export type DisableAccented = () => void;
219
+
220
+ export type Position = {
221
+ left: number;
222
+ top: number;
223
+ width: number;
224
+ height: number;
225
+ };
226
+
227
+ export type Issue = {
228
+ id: string;
229
+ title: string;
230
+ description: string;
231
+ url: string;
232
+ impact: axe.ImpactValue;
233
+ };
234
+
235
+ export type BaseElementWithIssues = {
236
+ element: HTMLElement | SVGElement;
237
+ rootNode: Node;
238
+ };
239
+
240
+ export type ElementWithIssues = BaseElementWithIssues & {
241
+ issues: Array<Issue>;
242
+ };
243
+
244
+ export type ExtendedElementWithIssues = BaseElementWithIssues & {
245
+ issues: Signal<ElementWithIssues['issues']>;
246
+ visible: Signal<boolean>;
247
+ trigger: AccentedTrigger;
248
+ position: Signal<Position>;
249
+ skipRender: boolean;
250
+ anchorNameValue: string;
251
+ scrollableAncestors: Signal<Set<Element>>;
252
+ id: number;
253
+ };
254
+
255
+ export type ScanContext = {
256
+ include: Array<Node>;
257
+ exclude: Array<Node>;
258
+ };
@@ -0,0 +1,11 @@
1
+ import type { BaseElementWithIssues } from '../types.ts';
2
+
3
+ export function areElementsWithIssuesEqual(
4
+ elementWithIssues1: BaseElementWithIssues,
5
+ elementWithIssues2: BaseElementWithIssues,
6
+ ) {
7
+ return (
8
+ elementWithIssues1.element === elementWithIssues2.element &&
9
+ elementWithIssues1.rootNode === elementWithIssues2.rootNode
10
+ );
11
+ }
@@ -0,0 +1,53 @@
1
+ import assert from 'node:assert/strict';
2
+ import { suite, test } from 'node:test';
3
+ import type { Issue } from '../types';
4
+ import { areIssueSetsEqual } from './are-issue-sets-equal';
5
+
6
+ const issue1: Issue = {
7
+ id: 'id1',
8
+ title: 'title1',
9
+ description: 'description1',
10
+ url: 'http://example.com',
11
+ impact: 'serious',
12
+ };
13
+
14
+ const issue2: Issue = {
15
+ id: 'id2',
16
+ title: 'title2',
17
+ description: 'description2',
18
+ url: 'http://example.com',
19
+ impact: 'serious',
20
+ };
21
+
22
+ // @ts-expect-error
23
+ const issue2Clone: Issue = Object.keys(issue2).reduce((obj, key) => {
24
+ // @ts-expect-error
25
+ obj[key] = issue2[key];
26
+ return obj;
27
+ }, {});
28
+
29
+ const issue3: Issue = {
30
+ id: 'id3',
31
+ title: 'title3',
32
+ description: 'description3',
33
+ url: 'http://example.com',
34
+ impact: 'serious',
35
+ };
36
+
37
+ suite('areIssueSetsEqual', () => {
38
+ test('returns true when both sets are empty', () => {
39
+ assert.ok(areIssueSetsEqual([], []));
40
+ });
41
+
42
+ test('returns true when both sets have equal elements, even when the order in the arrays doesn’t match', () => {
43
+ assert.ok(areIssueSetsEqual([issue1, issue2], [issue2Clone, issue1]));
44
+ });
45
+
46
+ test('returns false when sets have different length', () => {
47
+ assert.equal(areIssueSetsEqual([issue1, issue2], [issue1]), false);
48
+ });
49
+
50
+ test('returns false when sets have the same length, but one element differs', () => {
51
+ assert.equal(areIssueSetsEqual([issue1, issue2], [issue1, issue3]), false);
52
+ });
53
+ });
@@ -0,0 +1,12 @@
1
+ import type { Issue } from '../types.ts';
2
+
3
+ const issueProps: Array<keyof Issue> = ['id', 'title', 'description', 'url', 'impact'];
4
+
5
+ export function areIssueSetsEqual(issues1: Array<Issue>, issues2: Array<Issue>) {
6
+ return (
7
+ issues1.length === issues2.length &&
8
+ issues1.every((issue1) =>
9
+ Boolean(issues2.find((issue2) => issueProps.every((prop) => issue2[prop] === issue1[prop]))),
10
+ )
11
+ );
12
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Tests whether a particular combination of CSS property and value on an element
3
+ * makes that element a containing block.
4
+ * https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_display/Containing_block
5
+ *
6
+ * The function is meant to be run with properties that behave inconsistently across browsers.
7
+ *
8
+ * It's only meant to be used during initialization.
9
+ */
10
+ function testContainingBlockCreation<T extends keyof CSSStyleDeclaration>(
11
+ prop: T,
12
+ value: CSSStyleDeclaration[T],
13
+ ) {
14
+ const container = document.createElement('div');
15
+ container.style[prop] = value;
16
+ container.style.position = 'fixed';
17
+ container.style.insetInlineStart = '10px';
18
+ container.style.insetBlockStart = '10px';
19
+
20
+ const element = document.createElement('div');
21
+ element.style.position = 'fixed';
22
+ element.style.insetInlineStart = '0';
23
+ element.style.insetBlockStart = '0';
24
+
25
+ container.appendChild(element);
26
+ document.body.appendChild(container);
27
+ const containerRect = container.getBoundingClientRect();
28
+ const elementRect = element.getBoundingClientRect();
29
+
30
+ container.remove();
31
+
32
+ return containerRect.top === elementRect.top && containerRect.left === elementRect.left;
33
+ }
34
+
35
+ // This is the set we'll use to store the properties that _may_ create containing blocks
36
+ // (the behavior of the ones that we'll be checking is inconsistent across browsers
37
+ // at the time of writing this comment).
38
+ const propsAffectingContainingBlocks = new Set<keyof CSSStyleDeclaration>();
39
+
40
+ export function createsContainingBlock(prop: keyof CSSStyleDeclaration) {
41
+ return propsAffectingContainingBlocks.has(prop);
42
+ }
43
+
44
+ export function initializeContainingBlockSupportSet() {
45
+ type StyleEntry<T extends keyof CSSStyleDeclaration> = {
46
+ [K in T]: { prop: K; value: CSSStyleDeclaration[K] };
47
+ }[T];
48
+
49
+ const propsToTest: Array<StyleEntry<'filter' | 'backdropFilter' | 'containerType'>> = [
50
+ { prop: 'filter', value: 'blur(1px)' },
51
+ { prop: 'backdropFilter', value: 'blur(1px)' },
52
+ { prop: 'containerType', value: 'size' },
53
+ ];
54
+
55
+ for (const { prop, value } of propsToTest) {
56
+ if (testContainingBlockCreation(prop, value)) {
57
+ propsAffectingContainingBlocks.add(prop);
58
+ }
59
+ }
60
+ }
@@ -0,0 +1,54 @@
1
+ import assert from 'node:assert/strict';
2
+ import { suite, test } from 'node:test';
3
+ import { JSDOM } from 'jsdom';
4
+ import { contains } from './contains';
5
+
6
+ suite('contains', () => {
7
+ test('an element contains itself', () => {
8
+ const dom = new JSDOM('<div id="test"></div>');
9
+ const { document } = dom.window;
10
+ const element = document.querySelector('#test')!;
11
+
12
+ assert.equal(contains(element, element), true);
13
+ });
14
+
15
+ test('an element does not contain its sibling', () => {
16
+ const dom = new JSDOM('<div><div id="sibling1"></div><div id="sibling2"></div></div>');
17
+ const { document } = dom.window;
18
+ const sibling1 = document.querySelector('#sibling1')!;
19
+ const sibling2 = document.querySelector('#sibling2')!;
20
+
21
+ assert.equal(contains(sibling1, sibling2), false);
22
+ });
23
+
24
+ test('an element contain its descendant', () => {
25
+ const dom = new JSDOM('<div id="ancestor"><div id="descendant"></div></div>');
26
+ const { document } = dom.window;
27
+ const ancestor = document.querySelector('#ancestor')!;
28
+ const descendant = document.querySelector('#descendant')!;
29
+
30
+ assert.equal(contains(ancestor, descendant), true);
31
+ });
32
+
33
+ test('an element contain its descendant', () => {
34
+ const dom = new JSDOM('<div id="ancestor"><div id="descendant"></div></div>');
35
+ const { document } = dom.window;
36
+ const ancestor = document.querySelector('#ancestor')!;
37
+ const descendant = document.querySelector('#descendant')!;
38
+
39
+ assert.equal(contains(descendant, ancestor), false);
40
+ });
41
+
42
+ test('an element contain its descendant if the descendant is in a shadow DOM', () => {
43
+ const dom = new JSDOM('<div id="ancestor"><div id="host"></div></div>');
44
+ global.Node = dom.window.Node;
45
+ const { document } = dom.window;
46
+ const ancestor = document.querySelector('#ancestor')!;
47
+ const host = document.querySelector('#host')!;
48
+ const shadowRoot = host.attachShadow({ mode: 'open' });
49
+ shadowRoot.innerHTML = '<div id="descendant"></div>';
50
+ const descendant = shadowRoot.querySelector('#descendant')!;
51
+
52
+ assert.equal(contains(ancestor, descendant), true);
53
+ });
54
+ });
@@ -0,0 +1,19 @@
1
+ import { isDocumentFragment, isShadowRoot } from './dom-helpers.js';
2
+
3
+ export function contains(ancestor: Node, descendant: Node): boolean {
4
+ if (ancestor.contains(descendant)) {
5
+ return true;
6
+ }
7
+ let rootNode = descendant.getRootNode();
8
+ while (rootNode) {
9
+ if (!(isDocumentFragment(rootNode) && isShadowRoot(rootNode))) {
10
+ return false;
11
+ }
12
+ const host = rootNode.host;
13
+ if (ancestor.contains(host)) {
14
+ return true;
15
+ }
16
+ rootNode = host.getRootNode();
17
+ }
18
+ return false;
19
+ }
@@ -0,0 +1,3 @@
1
+ export function deduplicateNodes(nodes: Array<Node>): Array<Node> {
2
+ return [...new Set(nodes)];
3
+ }
@@ -0,0 +1,41 @@
1
+ import assert from 'node:assert/strict';
2
+ import { suite, test } from 'node:test';
3
+
4
+ import { deepMerge } from './deep-merge';
5
+
6
+ suite('deepMerge', () => {
7
+ test('merges two objects with overlapping keys', () => {
8
+ const target = { a: 1, b: 2 };
9
+ const source = { b: 3, c: 4 };
10
+ const result = deepMerge(target, source);
11
+ assert.deepEqual(result, { a: 1, b: 3, c: 4 });
12
+ });
13
+
14
+ test('deeply merges nested objects', () => {
15
+ const target = { a: 1, b: { x: 1, y: 2 } };
16
+ const source = { b: { y: 3, z: 4 }, c: 5 };
17
+ const result = deepMerge(target, source);
18
+ assert.deepEqual(result, { a: 1, b: { x: 1, y: 3, z: 4 }, c: 5 });
19
+ });
20
+
21
+ test('handles null values in a logical way', () => {
22
+ const target = { a: 1 };
23
+ const source = { a: null };
24
+ const result = deepMerge(target, source);
25
+ assert.deepEqual(result, { a: null });
26
+ });
27
+
28
+ test('doesn’t turn arrays into objects', () => {
29
+ const target = { a: [1, 2, 3] };
30
+ const source = { a: [4, 5] };
31
+ const result = deepMerge(target, source);
32
+ assert.deepEqual(result, { a: [4, 5] });
33
+ });
34
+
35
+ test('handles merging an object into a string in a logical way', () => {
36
+ const target = { a: 'hello' };
37
+ const source = { a: { b: 'bye' } };
38
+ const result = deepMerge(target, source);
39
+ assert.deepEqual(result, { a: { b: 'bye' } });
40
+ });
41
+ });
@@ -0,0 +1,24 @@
1
+ import { isNode } from './dom-helpers.js';
2
+
3
+ // biome-ignore lint/suspicious/noExplicitAny: I'm not sure how to type this properly
4
+ type AnyObject = Record<string, any>;
5
+
6
+ const isObject = (obj: unknown): obj is AnyObject =>
7
+ typeof obj === 'object' && obj !== null && !Array.isArray(obj);
8
+
9
+ export function deepMerge(target: AnyObject, source: AnyObject): AnyObject {
10
+ const output = { ...target };
11
+ for (const key of Object.keys(source)) {
12
+ if (isObject(source[key])) {
13
+ // Don't merge DOM nodes.
14
+ if (isObject(target[key]) && !isNode(target[key])) {
15
+ output[key] = deepMerge(target[key], source[key]);
16
+ } else {
17
+ output[key] = source[key];
18
+ }
19
+ } else {
20
+ output[key] = source[key];
21
+ }
22
+ }
23
+ return output;
24
+ }
@@ -0,0 +1,42 @@
1
+ export function isNode(obj: object): obj is Node {
2
+ return (
3
+ 'nodeType' in obj &&
4
+ typeof obj.nodeType === 'number' &&
5
+ 'nodeName' in obj &&
6
+ typeof obj.nodeName === 'string'
7
+ );
8
+ }
9
+
10
+ export function isNodeList(obj: object): obj is NodeList {
11
+ return Object.prototype.toString.call(obj) === '[object NodeList]';
12
+ }
13
+
14
+ export function isElement(node: Node): node is Element {
15
+ return typeof Node !== 'undefined' && node.nodeType === Node.ELEMENT_NODE;
16
+ }
17
+
18
+ export function isDocument(node: Node): node is Document {
19
+ return typeof Node !== 'undefined' && node.nodeType === Node.DOCUMENT_NODE;
20
+ }
21
+
22
+ export function isDocumentFragment(node: Node): node is DocumentFragment {
23
+ return typeof Node !== 'undefined' && node.nodeType === Node.DOCUMENT_FRAGMENT_NODE;
24
+ }
25
+
26
+ export function isShadowRoot(documentFragment: DocumentFragment): documentFragment is ShadowRoot {
27
+ return 'host' in documentFragment;
28
+ }
29
+
30
+ export function isHtmlElement(element: Element): element is HTMLElement {
31
+ // We can't use instanceof because it may not work across contexts
32
+ // (such as when an element is moved from an iframe).
33
+ // This heuristic seems to be the most robust and fastest that I could think of.
34
+ return element.constructor.name.startsWith('HTML');
35
+ }
36
+
37
+ export function isSvgElement(element: Element): element is SVGElement {
38
+ // We can't use instanceof because it may not work across contexts
39
+ // (such as when an element is moved from an iframe).
40
+ // This heuristic seems to be the most robust and fastest that I could think of.
41
+ return element.constructor.name.startsWith('SVG');
42
+ }
@@ -0,0 +1,6 @@
1
+ export function ensureNonEmpty<T>(arr: T[]): [T, ...T[]] {
2
+ if (arr.length === 0) {
3
+ throw new Error('Array must not be empty');
4
+ }
5
+ return arr as [T, ...T[]];
6
+ }
@@ -0,0 +1,15 @@
1
+ export function getElementHtml(element: Element) {
2
+ const outerHtml = element.outerHTML;
3
+ const innerHtml = element.innerHTML;
4
+ if (!innerHtml) {
5
+ return outerHtml;
6
+ }
7
+ const index = outerHtml.indexOf(innerHtml);
8
+ if (index === -1) {
9
+ // This shouldn't be happening, but if it does, we can just return the outer HTML.
10
+ return outerHtml;
11
+ }
12
+ const openingTag = outerHtml.slice(0, index);
13
+ const closingTag = outerHtml.slice(index + innerHtml.length);
14
+ return `${openingTag}…${closingTag}`;
15
+ }