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
@@ -0,0 +1,419 @@
1
+ import type { Signal } from '@preact/signals-core';
2
+ import { colorDark, colorLight, fontSystemMono, fontSystemSans } from '../common/tokens.js';
3
+ import { accentedUrl } from '../constants.js';
4
+ import { logAndRethrow } from '../log-and-rethrow.js';
5
+ import type { Issue } from '../types.ts';
6
+ import { getElementHtml } from '../utils/get-element-html.js';
7
+ import { isNonEmpty } from '../utils/is-non-empty.js';
8
+
9
+ export interface AccentedDialog extends HTMLElement {
10
+ issues: Signal<Array<Issue>> | undefined;
11
+ element: Element | undefined;
12
+ showModal: () => void;
13
+ open: boolean;
14
+ }
15
+
16
+ // We want Accented to not throw an error in Node, and use static imports,
17
+ // so we can't export `class extends HTMLElement` because HTMLElement is not available in Node.
18
+ export const getAccentedDialog = () => {
19
+ const dialogTemplate = document.createElement('template');
20
+ dialogTemplate.innerHTML = `
21
+ <dialog dir="ltr" lang="en" aria-labelledby="title">
22
+ <div id="button-container">
23
+ <button id="close" aria-label="Close">✕</button>
24
+ </div>
25
+ <h2 id="title">Accessibility issues</h2>
26
+ <section id="element-html-container" aria-label="Element">
27
+ <code id="element-html"></code>
28
+ </section>
29
+ <ul id="issues"></ul>
30
+ <section id="footer">
31
+ <p>
32
+ Powered by
33
+ <a href="${accentedUrl}" target="_blank" aria-description="Opens in new tab">Accented</a>
34
+ and
35
+ <a href="https://github.com/dequelabs/axe-core" target="_blank" aria-description="Opens in new tab">axe-core</a>.
36
+ </p>
37
+ </section>
38
+ </dialog>
39
+ `;
40
+
41
+ const issueTemplate = document.createElement('template');
42
+ issueTemplate.innerHTML = `
43
+ <li>
44
+ <a target="_blank" aria-description="Opens in new tab"></a>
45
+ <div class="impact"></div>
46
+ <div class="description"></div>
47
+ </li>
48
+ `;
49
+
50
+ const descriptionTemplate = document.createElement('template');
51
+ descriptionTemplate.innerHTML = `
52
+ <span></span>
53
+ <ul></ul>
54
+ `;
55
+
56
+ const stylesheet = new CSSStyleSheet();
57
+ stylesheet.replaceSync(`
58
+ :host {
59
+ all: initial !important;
60
+
61
+ /* OKLCH stuff: https://oklch.com/ */
62
+ --light-color: ${colorLight};
63
+ --dark-color: ${colorDark};
64
+
65
+ --background-color: light-dark(var(--light-color), var(--dark-color));
66
+ --text-color: light-dark(var(--dark-color), var(--light-color));
67
+
68
+ --impact-lightness: 0.80;
69
+ --focus-lightness: 0.45;
70
+ @media (prefers-color-scheme: dark) {
71
+ --impact-lightness: 0.45;
72
+ --focus-lightness: 0.80;
73
+ }
74
+
75
+ --blue-hue: 230;
76
+ --gold-hue: 90;
77
+ --red-hue: 0;
78
+
79
+ /* Contrasts with background. */
80
+ --focus-color: oklch(var(--focus-lightness) 0.25 var(--blue-hue));
81
+
82
+ --impact-chroma: 0.16;
83
+
84
+ --impact-moderate-hue: var(--blue-hue);
85
+ --impact-serious-hue: var(--gold-hue);
86
+ --impact-critical-hue: var(--red-hue);
87
+
88
+ --impact-minor-color: oklch(var(--impact-lightness) 0 0);
89
+ --impact-moderate-color: oklch(var(--impact-lightness) var(--impact-chroma) var(--impact-moderate-hue));
90
+ --impact-serious-color: oklch(var(--impact-lightness) var(--impact-chroma) var(--impact-serious-hue));
91
+ --impact-critical-color: oklch(var(--impact-lightness) var(--impact-chroma) var(--impact-critical-hue));
92
+
93
+ --base-size: max(1rem, 16px);
94
+
95
+ /* Spacing and typography custom props, inspired by https://utopia.fyi (simplified). */
96
+
97
+ /* @link https://utopia.fyi/type/calculator?c=320,16,1.2,1240,16,1.2,5,2,&s=0.75|0.5|0.25,1.5|2|3|4|6,s-l&g=s,l,xl,12 */
98
+ --ratio: 1.2;
99
+ --step-0: var(--base-size);
100
+ --step-1: calc(var(--step-0) * var(--ratio));
101
+ --step-2: calc(var(--step-1) * var(--ratio));
102
+ --step-3: calc(var(--step-2) * var(--ratio));
103
+ --step-4: calc(var(--step-3) * var(--ratio));
104
+ --step--1: calc(var(--step-0) / var(--ratio));
105
+
106
+ /* @link https://utopia.fyi/space/calculator?c=320,16,1.2,1240,16,1.2,5,2,&s=0.75|0.5|0.25,1.5|2|3|4|6,s-l&g=s,l,xl,12 */
107
+ --space-3xs: calc(0.25 * var(--base-size));
108
+ --space-2xs: calc(0.5 * var(--base-size));
109
+ --space-xs: calc(0.75 * var(--base-size));
110
+ --space-s: var(--base-size);
111
+ --space-m: calc(1.5 * var(--base-size));
112
+ --space-l: calc(2 * var(--base-size));
113
+ --space-xl: calc(3 * var(--base-size));
114
+ --space-2xl: calc(4 * var(--base-size));
115
+ --space-3xl: calc(6 * var(--base-size));
116
+ }
117
+
118
+ a[href], button {
119
+ outline-offset: 2px;
120
+ outline-color: var(--focus-color);
121
+ outline-width: 2px;
122
+ outline-style: none;
123
+
124
+ &:focus-visible {
125
+ outline-style: solid;
126
+ }
127
+
128
+ /* We should probably be comfortable with showing these styles on non-hover devices. */
129
+ &:hover:not(:focus-visible) {
130
+ outline-style: dashed;
131
+ }
132
+ }
133
+
134
+ a[href] {
135
+ color: currentColor;
136
+ }
137
+
138
+ a[href][target="_blank"]::after {
139
+ content: " ↗";
140
+ }
141
+
142
+ dialog {
143
+ box-sizing: border-box;
144
+ overflow-wrap: break-word;
145
+ font-family: ${fontSystemSans};
146
+ line-height: 1.5;
147
+ text-wrap: pretty;
148
+ background-color: var(--background-color);
149
+ color: var(--text-color);
150
+ border: 2px solid currentColor;
151
+ padding: var(--space-l);
152
+ inline-size: min(90ch, calc(100% - var(--space-s)* 2));
153
+ max-block-size: calc(100% - var(--space-s) * 2);
154
+
155
+ color-scheme: light dark;
156
+ }
157
+
158
+ #button-container {
159
+ text-align: end;
160
+ }
161
+
162
+ #close {
163
+ background-color: var(--background-color);
164
+ color: var(--text-color);
165
+ border: 2px solid currentColor;
166
+ padding-inline: var(--space-2xs);
167
+ aspect-ratio: 1 / 1;
168
+ font-size: var(--step-0);
169
+ }
170
+
171
+ h2 {
172
+ font-size: var(--step-4);
173
+ line-height: 1.2;
174
+ margin-block-start: var(--space-s);
175
+ margin-block-end: 0;
176
+ }
177
+
178
+ #element-html-container {
179
+ padding: var(--space-xs);
180
+ border: 2px solid currentColor;
181
+ margin-block-start: var(--space-l);
182
+ }
183
+
184
+ code {
185
+ font-family: ${fontSystemMono};
186
+ font-size: var(--step--1);
187
+ }
188
+
189
+ #issues {
190
+ font-size: var(--step-1);
191
+ margin-block-start: var(--space-l);
192
+ padding-inline: 0;
193
+ list-style: none;
194
+
195
+ & > li:not(:first-child) {
196
+ margin-block-start: var(--space-m);
197
+ }
198
+
199
+ a {
200
+ font-weight: 500;
201
+ }
202
+ }
203
+
204
+ .impact {
205
+ margin-block-start: var(--space-2xs);
206
+ font-size: var(--step--1);
207
+
208
+ inline-size: fit-content;
209
+ padding-inline: var(--space-3xs);
210
+
211
+ &[data-impact="minor"] {
212
+ background-color: var(--impact-minor-color);
213
+ }
214
+ &[data-impact="moderate"] {
215
+ background-color: var(--impact-moderate-color);
216
+ }
217
+ &[data-impact="serious"] {
218
+ background-color: var(--impact-serious-color);
219
+ }
220
+ &[data-impact="critical"] {
221
+ background-color: var(--impact-critical-color);
222
+ }
223
+ }
224
+
225
+ .description {
226
+ margin-block-start: var(--space-2xs);
227
+ font-size: var(--step--1);
228
+
229
+ li {
230
+ list-style-type: disc;
231
+ }
232
+ }
233
+
234
+ #footer {
235
+ margin-block-start: var(--space-l);
236
+ font-size: var(--step--1);
237
+
238
+ p {
239
+ margin: 0;
240
+ text-align: end;
241
+ }
242
+ }
243
+ `);
244
+
245
+ return class extends HTMLElement implements AccentedDialog {
246
+ #abortController: AbortController | undefined;
247
+
248
+ issues: Signal<Array<Issue>> | undefined;
249
+
250
+ element: Element | undefined;
251
+
252
+ open = false;
253
+
254
+ constructor() {
255
+ super();
256
+ try {
257
+ this.attachShadow({ mode: 'open' });
258
+ const content = dialogTemplate.content.cloneNode(true);
259
+ if (this.shadowRoot) {
260
+ this.shadowRoot.adoptedStyleSheets.push(stylesheet);
261
+ this.shadowRoot.append(content);
262
+ }
263
+ } catch (error) {
264
+ logAndRethrow(error);
265
+ }
266
+ }
267
+
268
+ connectedCallback() {
269
+ try {
270
+ if (this.shadowRoot) {
271
+ const { shadowRoot } = this;
272
+ const dialog = shadowRoot.querySelector('dialog');
273
+ const closeButton = shadowRoot.querySelector('#close');
274
+ this.#abortController = new AbortController();
275
+ closeButton?.addEventListener(
276
+ 'click',
277
+ () => {
278
+ try {
279
+ dialog?.close();
280
+ } catch (error) {
281
+ logAndRethrow(error);
282
+ }
283
+ },
284
+ { signal: this.#abortController.signal },
285
+ );
286
+
287
+ dialog?.addEventListener(
288
+ 'click',
289
+ (event) => {
290
+ try {
291
+ this.#onDialogClick(event);
292
+ } catch (error) {
293
+ logAndRethrow(error);
294
+ }
295
+ },
296
+ { signal: this.#abortController.signal },
297
+ );
298
+
299
+ dialog?.addEventListener(
300
+ 'keydown',
301
+ (event) => {
302
+ try {
303
+ if (event.key === 'Escape') {
304
+ event.stopPropagation();
305
+ }
306
+ } catch (error) {
307
+ logAndRethrow(error);
308
+ }
309
+ },
310
+ { signal: this.#abortController.signal },
311
+ );
312
+
313
+ if (this.issues) {
314
+ const issues = this.issues.value;
315
+ const issuesList = shadowRoot.getElementById('issues');
316
+ if (issuesList) {
317
+ issuesList.innerHTML = '';
318
+ for (const issue of issues) {
319
+ const issueContent = issueTemplate.content.cloneNode(true) as Element;
320
+ const title = issueContent.querySelector('a');
321
+ const impact = issueContent.querySelector('.impact');
322
+ const description = issueContent.querySelector('.description');
323
+ if (title && impact && description) {
324
+ title.textContent = `${issue.title} (${issue.id})`;
325
+ title.href = issue.url;
326
+
327
+ impact.textContent = `User impact: ${issue.impact}`;
328
+ impact.setAttribute('data-impact', String(issue.impact));
329
+
330
+ const descriptionItems = issue.description.split(/\n\s*/);
331
+ const descriptionContent = descriptionTemplate.content.cloneNode(true) as Element;
332
+ const descriptionTitle = descriptionContent.querySelector('span');
333
+ const descriptionList = descriptionContent.querySelector('ul');
334
+ if (
335
+ descriptionTitle &&
336
+ descriptionList &&
337
+ isNonEmpty(descriptionItems) &&
338
+ descriptionItems.length > 1
339
+ ) {
340
+ descriptionTitle.textContent = descriptionItems[0];
341
+ for (const descriptionItem of descriptionItems.slice(1)) {
342
+ const li = document.createElement('li');
343
+ li.textContent = descriptionItem;
344
+ descriptionList.appendChild(li);
345
+ }
346
+ description.appendChild(descriptionContent);
347
+ }
348
+ }
349
+ issuesList.appendChild(issueContent);
350
+ }
351
+ }
352
+ }
353
+
354
+ if (this.element) {
355
+ const elementHtmlContainer = shadowRoot.getElementById('element-html');
356
+ if (elementHtmlContainer) {
357
+ elementHtmlContainer.textContent = getElementHtml(this.element);
358
+ }
359
+ }
360
+
361
+ dialog?.addEventListener(
362
+ 'close',
363
+ () => {
364
+ try {
365
+ this.open = false;
366
+ this.dispatchEvent(new Event('close'));
367
+ } catch (error) {
368
+ logAndRethrow(error);
369
+ }
370
+ },
371
+ { signal: this.#abortController.signal },
372
+ );
373
+ }
374
+ } catch (error) {
375
+ logAndRethrow(error);
376
+ }
377
+ }
378
+
379
+ disconnectedCallback() {
380
+ try {
381
+ if (this.#abortController) {
382
+ this.#abortController.abort();
383
+ }
384
+ } catch (error) {
385
+ logAndRethrow(error);
386
+ }
387
+ }
388
+
389
+ showModal() {
390
+ if (this.shadowRoot) {
391
+ const dialog = this.shadowRoot.querySelector('dialog');
392
+ if (dialog) {
393
+ dialog.showModal();
394
+ this.open = true;
395
+ }
396
+ }
397
+ }
398
+
399
+ #onDialogClick(event: MouseEvent) {
400
+ const dialog = event.currentTarget as HTMLDialogElement;
401
+ if (
402
+ !dialog ||
403
+ typeof dialog.getBoundingClientRect !== 'function' ||
404
+ typeof dialog.close !== 'function'
405
+ ) {
406
+ return;
407
+ }
408
+ const rect = dialog.getBoundingClientRect();
409
+ const isInsideDialog =
410
+ event.clientX >= rect.left &&
411
+ event.clientX <= rect.right &&
412
+ event.clientY >= rect.top &&
413
+ event.clientY <= rect.bottom;
414
+ if (!isInsideDialog) {
415
+ dialog.close();
416
+ }
417
+ }
418
+ };
419
+ };
@@ -0,0 +1,251 @@
1
+ import { effect } from '@preact/signals-core';
2
+ import type { Signal } from '@preact/signals-core';
3
+ import { fontSystemSans } from '../common/tokens.js';
4
+ import { logAndRethrow } from '../log-and-rethrow.js';
5
+ import type { Position } from '../types.ts';
6
+ import { supportsAnchorPositioning } from '../utils/supports-anchor-positioning.js';
7
+ import type { AccentedDialog } from './accented-dialog.ts';
8
+
9
+ export interface AccentedTrigger extends HTMLElement {
10
+ element: Element | undefined;
11
+ dialog: AccentedDialog | undefined;
12
+ position: Signal<Position> | undefined;
13
+ visible: Signal<boolean> | undefined;
14
+ }
15
+
16
+ // We want Accented to not throw an error in Node, and use static imports,
17
+ // so we can't export `class extends HTMLElement` because HTMLElement is not available in Node.
18
+ export const getAccentedTrigger = (name: string) => {
19
+ const template = document.createElement('template');
20
+
21
+ // I initially tried creating a CSSStyelSheet object with styles instead of having a <style> element in the template,
22
+ // but that led to a hard-to-catch layout bug in Safari in CI that caused a test to fail.
23
+ // It seems that when using adoptedStyleSheets, the styles may be applied asynchronously,
24
+ // which may have caused the layout bug.
25
+ // Using a <style> element does not seem to lead to any performance issues, so I'm keeping it this way.
26
+ template.innerHTML = `
27
+ <style>
28
+ :host {
29
+ --ratio: 1.2;
30
+ --base-size: max(1rem, 16px);
31
+ position: fixed !important;
32
+ inset-inline-start: anchor(self-start) !important;
33
+ inset-inline-end: anchor(self-end) !important;
34
+ inset-block-start: anchor(self-start) !important;
35
+ inset-block-end: anchor(self-end) !important;
36
+
37
+ /* Revert potential effects of white-space: pre; set on a trigger's ancestor. */
38
+ white-space: normal !important;
39
+
40
+ pointer-events: none !important;
41
+ }
42
+
43
+ #trigger {
44
+ pointer-events: auto;
45
+
46
+ user-select: none;
47
+
48
+ margin-inline-start: auto;
49
+ margin-inline-end: 4px;
50
+ margin-block-start: 4px;
51
+
52
+ box-sizing: border-box;
53
+ font-family: ${fontSystemSans};
54
+ font-size: calc(var(--ratio) * var(--ratio) * var(--base-size));
55
+ inline-size: calc(2 * var(--base-size));
56
+ block-size: calc(2 * var(--base-size));
57
+
58
+ display: flex;
59
+ align-items: center;
60
+ justify-content: center;
61
+
62
+ /* Make it look better in forced-colors mode. */
63
+ border: 2px solid transparent;
64
+
65
+ background-color: var(--${name}-primary-color);
66
+ color: var(--${name}-secondary-color);
67
+
68
+ padding: 0;
69
+
70
+ border-radius: calc(0.25 * var(--base-size));
71
+
72
+ outline-offset: -4px;
73
+ outline-color: currentColor;
74
+ outline-width: 2px;
75
+ outline-style: none;
76
+
77
+ &:focus-visible {
78
+ outline-style: solid;
79
+ }
80
+
81
+ /* We should probably be comfortable with showing these styles on non-hover devices. */
82
+ &:hover:not(:focus-visible) {
83
+ outline-style: dashed;
84
+ }
85
+ }
86
+ </style>
87
+ <button id="trigger" lang="en">á</button>
88
+ `;
89
+
90
+ return class extends HTMLElement implements AccentedTrigger {
91
+ #abortController: AbortController | undefined;
92
+
93
+ #dialogCloseAbortController: AbortController | undefined;
94
+
95
+ #disposeOfPositionEffect: (() => void) | undefined;
96
+
97
+ #disposeOfVisibilityEffect: (() => void) | undefined;
98
+
99
+ #elementMutationObserver: MutationObserver | undefined;
100
+
101
+ element: Element | undefined;
102
+
103
+ dialog: AccentedDialog | undefined;
104
+
105
+ position: Signal<Position> | undefined;
106
+
107
+ visible: Signal<boolean> | undefined;
108
+
109
+ constructor() {
110
+ super();
111
+ try {
112
+ this.attachShadow({ mode: 'open' });
113
+ const content = template.content.cloneNode(true);
114
+ if (this.shadowRoot) {
115
+ this.shadowRoot.append(content);
116
+ }
117
+ } catch (error) {
118
+ logAndRethrow(error);
119
+ }
120
+ }
121
+
122
+ connectedCallback() {
123
+ try {
124
+ if (this.shadowRoot) {
125
+ const { shadowRoot } = this;
126
+ const trigger = shadowRoot.getElementById('trigger');
127
+ if (trigger && this.element) {
128
+ trigger.ariaLabel = `Accessibility issues in ${this.element.nodeName.toLowerCase()}`;
129
+ }
130
+
131
+ this.#setTransform();
132
+
133
+ this.#elementMutationObserver = new MutationObserver(() => {
134
+ try {
135
+ this.#setTransform();
136
+ } catch (error) {
137
+ logAndRethrow(error);
138
+ }
139
+ });
140
+
141
+ if (this.element) {
142
+ this.#elementMutationObserver.observe(this.element, {
143
+ attributes: true,
144
+ });
145
+ }
146
+
147
+ this.#abortController = new AbortController();
148
+ trigger?.addEventListener(
149
+ 'click',
150
+ (event) => {
151
+ try {
152
+ // event.preventDefault() ensures that if the issue is within a link,
153
+ // the link's default behavior (following the URL) is prevented.
154
+ event.preventDefault();
155
+
156
+ // event.stopPropagation() ensures that if there's a click handler on the trigger's ancestor
157
+ // (a link, or a button, or anything else), it doesn't get triggered.
158
+ event.stopPropagation();
159
+
160
+ // We append the dialog when the button is clicked,
161
+ // and remove it from the DOM when the dialog is closed.
162
+ // This gives us a performance improvement since Axe
163
+ // scan time seems to depend on the number of elements in the DOM.
164
+ if (this.dialog) {
165
+ this.#dialogCloseAbortController = new AbortController();
166
+ document.body.append(this.dialog);
167
+ this.dialog.showModal();
168
+ this.dialog.addEventListener(
169
+ 'close',
170
+ () => {
171
+ try {
172
+ this.dialog?.remove();
173
+ this.#dialogCloseAbortController?.abort();
174
+ } catch (error) {
175
+ logAndRethrow(error);
176
+ }
177
+ },
178
+ { signal: this.#dialogCloseAbortController.signal },
179
+ );
180
+ }
181
+ } catch (error) {
182
+ logAndRethrow(error);
183
+ }
184
+ },
185
+ { signal: this.#abortController.signal },
186
+ );
187
+
188
+ if (!supportsAnchorPositioning(window)) {
189
+ this.#disposeOfPositionEffect = effect(() => {
190
+ if (this.position && trigger) {
191
+ const position = this.position.value;
192
+ this.style.setProperty('top', `${position.top}px`, 'important');
193
+ this.style.setProperty('left', `${position.left}px`, 'important');
194
+ this.style.setProperty('width', `${position.width}px`, 'important');
195
+ this.style.setProperty('height', `${position.height}px`, 'important');
196
+ }
197
+ });
198
+ }
199
+ this.#disposeOfVisibilityEffect = effect(() => {
200
+ this.style.setProperty(
201
+ 'visibility',
202
+ this.visible?.value ? 'visible' : 'hidden',
203
+ 'important',
204
+ );
205
+ });
206
+ }
207
+ } catch (error) {
208
+ logAndRethrow(error);
209
+ }
210
+ }
211
+
212
+ disconnectedCallback() {
213
+ try {
214
+ if (this.#abortController) {
215
+ this.#abortController.abort();
216
+ }
217
+ if (this.#dialogCloseAbortController && !this.dialog?.open) {
218
+ this.#dialogCloseAbortController.abort();
219
+ this.dialog?.remove();
220
+ }
221
+ if (this.#disposeOfPositionEffect) {
222
+ this.#disposeOfPositionEffect();
223
+ this.#disposeOfPositionEffect = undefined;
224
+ }
225
+ if (this.#disposeOfVisibilityEffect) {
226
+ this.#disposeOfVisibilityEffect();
227
+ this.#disposeOfVisibilityEffect = undefined;
228
+ }
229
+ if (this.#elementMutationObserver) {
230
+ this.#elementMutationObserver.disconnect();
231
+ }
232
+ } catch (error) {
233
+ logAndRethrow(error);
234
+ }
235
+ }
236
+
237
+ #setTransform() {
238
+ // We read and write values in separate animation frames to avoid layout thrashing.
239
+ window.requestAnimationFrame(() => {
240
+ if (this.element) {
241
+ const transform = window.getComputedStyle(this.element).getPropertyValue('transform');
242
+ if (transform !== 'none') {
243
+ window.requestAnimationFrame(() => {
244
+ this.style.setProperty('transform', transform, 'important');
245
+ });
246
+ }
247
+ }
248
+ });
249
+ }
250
+ };
251
+ };
@@ -0,0 +1,21 @@
1
+ import { logAndRethrow } from './log-and-rethrow.js';
2
+ import { recalculatePositions } from './utils/recalculate-positions.js';
3
+
4
+ export function setupResizeListener() {
5
+ const abortController = new AbortController();
6
+ window.addEventListener(
7
+ 'fullscreenchange',
8
+ () => {
9
+ try {
10
+ recalculatePositions();
11
+ } catch (error) {
12
+ logAndRethrow(error);
13
+ }
14
+ },
15
+ { signal: abortController.signal },
16
+ );
17
+
18
+ return () => {
19
+ abortController.abort();
20
+ };
21
+ }