accented 0.0.2 → 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 (212) hide show
  1. package/NOTICE +14 -0
  2. package/README.md +44 -187
  3. package/dist/accented.d.ts +8 -8
  4. package/dist/accented.d.ts.map +1 -1
  5. package/dist/accented.js +37 -30
  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 +2 -1
  12. package/dist/constants.d.ts.map +1 -1
  13. package/dist/constants.js +2 -1
  14. package/dist/constants.js.map +1 -1
  15. package/dist/dom-updater.d.ts +1 -1
  16. package/dist/dom-updater.d.ts.map +1 -1
  17. package/dist/dom-updater.js +73 -31
  18. package/dist/dom-updater.js.map +1 -1
  19. package/dist/elements/accented-dialog.d.ts +13 -10
  20. package/dist/elements/accented-dialog.d.ts.map +1 -1
  21. package/dist/elements/accented-dialog.js +110 -94
  22. package/dist/elements/accented-dialog.js.map +1 -1
  23. package/dist/elements/accented-trigger.d.ts +14 -9
  24. package/dist/elements/accented-trigger.d.ts.map +1 -1
  25. package/dist/elements/accented-trigger.js +77 -22
  26. package/dist/elements/accented-trigger.js.map +1 -1
  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 +1 -1
  32. package/dist/intersection-observer.d.ts.map +1 -1
  33. package/dist/intersection-observer.js +12 -6
  34. package/dist/intersection-observer.js.map +1 -1
  35. package/dist/log-and-rethrow.d.ts +1 -1
  36. package/dist/log-and-rethrow.d.ts.map +1 -1
  37. package/dist/log-and-rethrow.js +2 -3
  38. package/dist/log-and-rethrow.js.map +1 -1
  39. package/dist/logger.d.ts +1 -1
  40. package/dist/logger.d.ts.map +1 -1
  41. package/dist/logger.js +6 -3
  42. package/dist/logger.js.map +1 -1
  43. package/dist/register-elements.d.ts +1 -1
  44. package/dist/register-elements.d.ts.map +1 -1
  45. package/dist/register-elements.js +6 -7
  46. package/dist/register-elements.js.map +1 -1
  47. package/dist/resize-listener.d.ts +1 -1
  48. package/dist/resize-listener.d.ts.map +1 -1
  49. package/dist/resize-listener.js +3 -4
  50. package/dist/resize-listener.js.map +1 -1
  51. package/dist/scanner.d.ts +2 -2
  52. package/dist/scanner.d.ts.map +1 -1
  53. package/dist/scanner.js +76 -43
  54. package/dist/scanner.js.map +1 -1
  55. package/dist/scroll-listeners.d.ts +1 -1
  56. package/dist/scroll-listeners.d.ts.map +1 -1
  57. package/dist/scroll-listeners.js +3 -4
  58. package/dist/scroll-listeners.js.map +1 -1
  59. package/dist/state.d.ts +3 -2
  60. package/dist/state.d.ts.map +1 -1
  61. package/dist/state.js +5 -3
  62. package/dist/state.js.map +1 -1
  63. package/dist/task-queue.d.ts +4 -4
  64. package/dist/task-queue.d.ts.map +1 -1
  65. package/dist/task-queue.js +3 -2
  66. package/dist/task-queue.js.map +1 -1
  67. package/dist/types.d.ts +140 -49
  68. package/dist/types.d.ts.map +1 -1
  69. package/dist/types.js.map +1 -1
  70. package/dist/utils/are-elements-with-issues-equal.d.ts +3 -0
  71. package/dist/utils/are-elements-with-issues-equal.d.ts.map +1 -0
  72. package/dist/utils/are-elements-with-issues-equal.js +5 -0
  73. package/dist/utils/are-elements-with-issues-equal.js.map +1 -0
  74. package/dist/utils/are-issue-sets-equal.d.ts +2 -2
  75. package/dist/utils/are-issue-sets-equal.d.ts.map +1 -1
  76. package/dist/utils/are-issue-sets-equal.js +3 -3
  77. package/dist/utils/are-issue-sets-equal.js.map +1 -1
  78. package/dist/utils/containing-blocks.d.ts +3 -0
  79. package/dist/utils/containing-blocks.d.ts.map +1 -0
  80. package/dist/utils/containing-blocks.js +46 -0
  81. package/dist/utils/containing-blocks.js.map +1 -0
  82. package/dist/utils/contains.d.ts +2 -0
  83. package/dist/utils/contains.d.ts.map +1 -0
  84. package/dist/utils/contains.js +19 -0
  85. package/dist/utils/contains.js.map +1 -0
  86. package/dist/utils/deduplicate-nodes.d.ts +2 -0
  87. package/dist/utils/deduplicate-nodes.d.ts.map +1 -0
  88. package/dist/utils/deduplicate-nodes.js +4 -0
  89. package/dist/utils/deduplicate-nodes.js.map +1 -0
  90. package/dist/utils/deep-merge.d.ts +1 -1
  91. package/dist/utils/deep-merge.d.ts.map +1 -1
  92. package/dist/utils/deep-merge.js +8 -5
  93. package/dist/utils/deep-merge.js.map +1 -1
  94. package/dist/utils/dom-helpers.d.ts +9 -0
  95. package/dist/utils/dom-helpers.d.ts.map +1 -0
  96. package/dist/utils/dom-helpers.js +34 -0
  97. package/dist/utils/dom-helpers.js.map +1 -0
  98. package/dist/utils/ensure-non-empty.d.ts +2 -0
  99. package/dist/utils/ensure-non-empty.d.ts.map +1 -0
  100. package/dist/utils/ensure-non-empty.js +7 -0
  101. package/dist/utils/ensure-non-empty.js.map +1 -0
  102. package/dist/utils/get-element-html.d.ts +1 -1
  103. package/dist/utils/get-element-html.d.ts.map +1 -1
  104. package/dist/utils/get-element-html.js +4 -2
  105. package/dist/utils/get-element-html.js.map +1 -1
  106. package/dist/utils/get-element-position.d.ts +10 -2
  107. package/dist/utils/get-element-position.d.ts.map +1 -1
  108. package/dist/utils/get-element-position.js +64 -16
  109. package/dist/utils/get-element-position.js.map +1 -1
  110. package/dist/utils/get-parent.d.ts +2 -0
  111. package/dist/utils/get-parent.d.ts.map +1 -0
  112. package/dist/utils/get-parent.js +12 -0
  113. package/dist/utils/get-parent.js.map +1 -0
  114. package/dist/utils/get-scan-context.d.ts +3 -0
  115. package/dist/utils/get-scan-context.d.ts.map +1 -0
  116. package/dist/utils/get-scan-context.js +28 -0
  117. package/dist/utils/get-scan-context.js.map +1 -0
  118. package/dist/utils/get-scrollable-ancestors.d.ts +1 -1
  119. package/dist/utils/get-scrollable-ancestors.d.ts.map +1 -1
  120. package/dist/utils/get-scrollable-ancestors.js +10 -6
  121. package/dist/utils/get-scrollable-ancestors.js.map +1 -1
  122. package/dist/utils/is-node-in-scan-context.d.ts +3 -0
  123. package/dist/utils/is-node-in-scan-context.d.ts.map +1 -0
  124. package/dist/utils/is-node-in-scan-context.js +26 -0
  125. package/dist/utils/is-node-in-scan-context.js.map +1 -0
  126. package/dist/utils/is-non-empty.d.ts +2 -0
  127. package/dist/utils/is-non-empty.d.ts.map +1 -0
  128. package/dist/utils/is-non-empty.js +4 -0
  129. package/dist/utils/is-non-empty.js.map +1 -0
  130. package/dist/utils/normalize-context.d.ts +3 -0
  131. package/dist/utils/normalize-context.d.ts.map +1 -0
  132. package/dist/utils/normalize-context.js +59 -0
  133. package/dist/utils/normalize-context.js.map +1 -0
  134. package/dist/utils/recalculate-positions.d.ts +1 -1
  135. package/dist/utils/recalculate-positions.d.ts.map +1 -1
  136. package/dist/utils/recalculate-positions.js +5 -5
  137. package/dist/utils/recalculate-positions.js.map +1 -1
  138. package/dist/utils/recalculate-scrollable-ancestors.d.ts +1 -1
  139. package/dist/utils/recalculate-scrollable-ancestors.d.ts.map +1 -1
  140. package/dist/utils/recalculate-scrollable-ancestors.js +4 -4
  141. package/dist/utils/recalculate-scrollable-ancestors.js.map +1 -1
  142. package/dist/utils/shadow-dom-aware-mutation-observer.d.ts +10 -0
  143. package/dist/utils/shadow-dom-aware-mutation-observer.d.ts.map +1 -0
  144. package/dist/utils/shadow-dom-aware-mutation-observer.js +61 -0
  145. package/dist/utils/shadow-dom-aware-mutation-observer.js.map +1 -0
  146. package/dist/utils/supports-anchor-positioning.d.ts +1 -1
  147. package/dist/utils/supports-anchor-positioning.d.ts.map +1 -1
  148. package/dist/utils/supports-anchor-positioning.js +1 -1
  149. package/dist/utils/supports-anchor-positioning.js.map +1 -1
  150. package/dist/utils/transform-violations.d.ts +2 -2
  151. package/dist/utils/transform-violations.d.ts.map +1 -1
  152. package/dist/utils/transform-violations.js +23 -10
  153. package/dist/utils/transform-violations.js.map +1 -1
  154. package/dist/utils/update-elements-with-issues.d.ts +11 -5
  155. package/dist/utils/update-elements-with-issues.d.ts.map +1 -1
  156. package/dist/utils/update-elements-with-issues.js +56 -24
  157. package/dist/utils/update-elements-with-issues.js.map +1 -1
  158. package/dist/validate-options.d.ts +2 -2
  159. package/dist/validate-options.d.ts.map +1 -1
  160. package/dist/validate-options.js +91 -4
  161. package/dist/validate-options.js.map +1 -1
  162. package/package.json +15 -7
  163. package/src/accented.test.ts +2 -2
  164. package/src/accented.ts +45 -34
  165. package/src/common/tokens.ts +10 -0
  166. package/src/constants.ts +2 -1
  167. package/src/dom-updater.ts +87 -34
  168. package/src/elements/accented-dialog.ts +157 -122
  169. package/src/elements/accented-trigger.ts +119 -47
  170. package/src/fullscreen-listener.ts +21 -0
  171. package/src/intersection-observer.ts +27 -16
  172. package/src/log-and-rethrow.ts +2 -3
  173. package/src/logger.ts +14 -4
  174. package/src/register-elements.ts +7 -7
  175. package/src/resize-listener.ts +15 -11
  176. package/src/scanner.ts +113 -57
  177. package/src/scroll-listeners.ts +27 -19
  178. package/src/state.ts +27 -16
  179. package/src/task-queue.test.ts +5 -4
  180. package/src/task-queue.ts +8 -6
  181. package/src/types.ts +179 -76
  182. package/src/utils/are-elements-with-issues-equal.ts +11 -0
  183. package/src/utils/are-issue-sets-equal.test.ts +10 -6
  184. package/src/utils/are-issue-sets-equal.ts +8 -6
  185. package/src/utils/containing-blocks.ts +60 -0
  186. package/src/utils/contains.test.ts +54 -0
  187. package/src/utils/contains.ts +19 -0
  188. package/src/utils/deduplicate-nodes.ts +3 -0
  189. package/src/utils/deep-merge.test.ts +8 -1
  190. package/src/utils/deep-merge.ts +14 -8
  191. package/src/utils/dom-helpers.ts +42 -0
  192. package/src/utils/ensure-non-empty.ts +6 -0
  193. package/src/utils/get-element-html.ts +4 -2
  194. package/src/utils/get-element-position.ts +84 -16
  195. package/src/utils/get-parent.ts +14 -0
  196. package/src/utils/get-scan-context.test.ts +85 -0
  197. package/src/utils/get-scan-context.ts +36 -0
  198. package/src/utils/get-scrollable-ancestors.ts +15 -7
  199. package/src/utils/is-node-in-scan-context.test.ts +70 -0
  200. package/src/utils/is-node-in-scan-context.ts +29 -0
  201. package/src/utils/is-non-empty.ts +3 -0
  202. package/src/utils/normalize-context.test.ts +105 -0
  203. package/src/utils/normalize-context.ts +65 -0
  204. package/src/utils/recalculate-positions.ts +5 -5
  205. package/src/utils/recalculate-scrollable-ancestors.ts +4 -4
  206. package/src/utils/shadow-dom-aware-mutation-observer.ts +75 -0
  207. package/src/utils/supports-anchor-positioning.ts +3 -3
  208. package/src/utils/transform-violations.test.ts +28 -24
  209. package/src/utils/transform-violations.ts +30 -12
  210. package/src/utils/update-elements-with-issues.test.ts +139 -51
  211. package/src/utils/update-elements-with-issues.ts +123 -54
  212. package/src/validate-options.ts +154 -14
@@ -1,19 +1,21 @@
1
- import type { Issue } from '../types';
2
1
  import type { Signal } from '@preact/signals-core';
3
- import { effect } from '@preact/signals-core';
4
- import getElementHtml from '../utils/get-element-html.js';
2
+ import { colorDark, colorLight, fontSystemMono, fontSystemSans } from '../common/tokens.js';
5
3
  import { accentedUrl } from '../constants.js';
6
- import logAndRethrow from '../log-and-rethrow.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';
7
8
 
8
9
  export interface AccentedDialog extends HTMLElement {
9
10
  issues: Signal<Array<Issue>> | undefined;
10
11
  element: Element | undefined;
11
12
  showModal: () => void;
13
+ open: boolean;
12
14
  }
13
15
 
14
16
  // We want Accented to not throw an error in Node, and use static imports,
15
17
  // so we can't export `class extends HTMLElement` because HTMLElement is not available in Node.
16
- export default () => {
18
+ export const getAccentedDialog = () => {
17
19
  const dialogTemplate = document.createElement('template');
18
20
  dialogTemplate.innerHTML = `
19
21
  <dialog dir="ltr" lang="en" aria-labelledby="title">
@@ -56,20 +58,45 @@ export default () => {
56
58
  :host {
57
59
  all: initial !important;
58
60
 
59
- --light-color: white;
60
- --dark-color: black;
61
- --focus-color: #0078d4; /* Contrasts with both white and black. */
61
+ /* OKLCH stuff: https://oklch.com/ */
62
+ --light-color: ${colorLight};
63
+ --dark-color: ${colorDark};
62
64
 
63
- --impact-minor-color: lightgray;
64
- --impact-moderate-color: gold;
65
- --impact-serious-color: #ff9e00;
66
- --impact-critical-color: #f883ec;
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);
67
94
 
68
95
  /* Spacing and typography custom props, inspired by https://utopia.fyi (simplified). */
69
96
 
70
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 */
71
98
  --ratio: 1.2;
72
- --step-0: 1rem;
99
+ --step-0: var(--base-size);
73
100
  --step-1: calc(var(--step-0) * var(--ratio));
74
101
  --step-2: calc(var(--step-1) * var(--ratio));
75
102
  --step-3: calc(var(--step-2) * var(--ratio));
@@ -77,15 +104,15 @@ export default () => {
77
104
  --step--1: calc(var(--step-0) / var(--ratio));
78
105
 
79
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 */
80
- --space-3xs: 0.25rem;
81
- --space-2xs: 0.5rem;
82
- --space-xs: 0.75rem;
83
- --space-s: 1rem;
84
- --space-m: 1.5rem;
85
- --space-l: 2rem;
86
- --space-xl: 3rem;
87
- --space-2xl: 4rem;
88
- --space-3xl: 6rem;
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));
89
116
  }
90
117
 
91
118
  a[href], button {
@@ -98,6 +125,7 @@ export default () => {
98
125
  outline-style: solid;
99
126
  }
100
127
 
128
+ /* We should probably be comfortable with showing these styles on non-hover devices. */
101
129
  &:hover:not(:focus-visible) {
102
130
  outline-style: dashed;
103
131
  }
@@ -114,14 +142,17 @@ export default () => {
114
142
  dialog {
115
143
  box-sizing: border-box;
116
144
  overflow-wrap: break-word;
117
- font-family: system-ui;
145
+ font-family: ${fontSystemSans};
118
146
  line-height: 1.5;
119
- background-color: var(--light-color);
120
- color: var(--dark-color);
147
+ text-wrap: pretty;
148
+ background-color: var(--background-color);
149
+ color: var(--text-color);
121
150
  border: 2px solid currentColor;
122
151
  padding: var(--space-l);
123
152
  inline-size: min(90ch, calc(100% - var(--space-s)* 2));
124
153
  max-block-size: calc(100% - var(--space-s) * 2);
154
+
155
+ color-scheme: light dark;
125
156
  }
126
157
 
127
158
  #button-container {
@@ -129,8 +160,8 @@ export default () => {
129
160
  }
130
161
 
131
162
  #close {
132
- background-color: var(--light-color);
133
- color: var(--dark-color);
163
+ background-color: var(--background-color);
164
+ color: var(--text-color);
134
165
  border: 2px solid currentColor;
135
166
  padding-inline: var(--space-2xs);
136
167
  aspect-ratio: 1 / 1;
@@ -151,8 +182,7 @@ export default () => {
151
182
  }
152
183
 
153
184
  code {
154
- /* https://systemfontstack.com/ */
155
- font-family: Menlo, Consolas, Monaco, Liberation Mono, Lucida Console, monospace;
185
+ font-family: ${fontSystemMono};
156
186
  font-size: var(--step--1);
157
187
  }
158
188
 
@@ -167,7 +197,7 @@ export default () => {
167
197
  }
168
198
 
169
199
  a {
170
- font-weight: bold;
200
+ font-weight: 500;
171
201
  }
172
202
  }
173
203
 
@@ -213,19 +243,17 @@ export default () => {
213
243
  `);
214
244
 
215
245
  return class extends HTMLElement implements AccentedDialog {
216
- #disposeOfEffect: (() => void) | undefined;
217
-
218
246
  #abortController: AbortController | undefined;
219
247
 
220
248
  issues: Signal<Array<Issue>> | undefined;
221
249
 
222
250
  element: Element | undefined;
223
251
 
224
- #elementMutationObserver: MutationObserver | undefined;
252
+ open = false;
225
253
 
226
254
  constructor() {
255
+ super();
227
256
  try {
228
- super();
229
257
  this.attachShadow({ mode: 'open' });
230
258
  const content = dialogTemplate.content.cloneNode(true);
231
259
  if (this.shadowRoot) {
@@ -244,96 +272,104 @@ export default () => {
244
272
  const dialog = shadowRoot.querySelector('dialog');
245
273
  const closeButton = shadowRoot.querySelector('#close');
246
274
  this.#abortController = new AbortController();
247
- closeButton?.addEventListener('click', () => {
248
- try {
249
- dialog?.close();
250
- } catch (error) {
251
- logAndRethrow(error);
252
- }
253
- }, { signal: this.#abortController.signal });
254
-
255
- dialog?.addEventListener('click', (event) => {
256
- try {
257
- this.#onDialogClick(event);
258
- } catch (error) {
259
- logAndRethrow(error);
260
- }
261
- }, { signal: this.#abortController.signal });
262
-
263
- this.#disposeOfEffect = effect(() => {
264
- if (this.issues) {
265
- const issues = this.issues.value;
266
- const issuesList = shadowRoot.getElementById('issues');
267
- if (issuesList) {
268
- issuesList.innerHTML = '';
269
- for (const issue of issues) {
270
- const issueContent = issueTemplate.content.cloneNode(true) as Element;
271
- const title = issueContent.querySelector('a');
272
- const impact = issueContent.querySelector('.impact');
273
- const description = issueContent.querySelector('.description');
274
- if (title && impact && description) {
275
- title.textContent = issue.title + ' (' + issue.id + ')';
276
- title.href = issue.url;
277
-
278
- impact.textContent = 'User impact: ' + issue.impact;
279
- impact.setAttribute('data-impact', String(issue.impact));
280
-
281
- const descriptionItems = issue.description.split(/\n\s*/);
282
- const descriptionContent = descriptionTemplate.content.cloneNode(true) as Element;
283
- const descriptionTitle = descriptionContent.querySelector('span');
284
- const descriptionList = descriptionContent.querySelector('ul');
285
- if (descriptionTitle && descriptionList && descriptionItems.length > 1) {
286
- descriptionTitle.textContent = descriptionItems[0]!;
287
- for (const descriptionItem of descriptionItems.slice(1)) {
288
- const li = document.createElement('li');
289
- li.textContent = descriptionItem;
290
- descriptionList.appendChild(li);
291
- }
292
- description.appendChild(descriptionContent);
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);
293
345
  }
346
+ description.appendChild(descriptionContent);
294
347
  }
295
- issuesList.appendChild(issueContent);
296
348
  }
349
+ issuesList.appendChild(issueContent);
297
350
  }
298
351
  }
299
- });
300
-
301
- const updateElementHtml = () => {
302
- if (this.element) {
303
- const elementHtmlContainer = shadowRoot.getElementById('element-html');
304
- if (elementHtmlContainer) {
305
- elementHtmlContainer.textContent = getElementHtml(this.element);
306
- }
307
- }
308
- };
309
-
310
- updateElementHtml();
352
+ }
311
353
 
312
- this.#elementMutationObserver = new MutationObserver(() => {
313
- try {
314
- updateElementHtml();
315
- } catch (error) {
316
- logAndRethrow(error);
317
- }
318
- });
319
354
  if (this.element) {
320
- // We're only outputting the element itself, not its subtree.
321
- // However, we're still listening for childList changes, because
322
- // we display an ellipsis if the element has innerHTML,
323
- // and we leave it empty if the element is empty.
324
- this.#elementMutationObserver.observe(this.element, {
325
- attributes: true,
326
- childList: true
327
- });
355
+ const elementHtmlContainer = shadowRoot.getElementById('element-html');
356
+ if (elementHtmlContainer) {
357
+ elementHtmlContainer.textContent = getElementHtml(this.element);
358
+ }
328
359
  }
329
360
 
330
- dialog?.addEventListener('close', () => {
331
- try {
332
- this.dispatchEvent(new Event('close'));
333
- } catch (error) {
334
- logAndRethrow(error);
335
- }
336
- }, { signal: this.#abortController.signal });
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
+ );
337
373
  }
338
374
  } catch (error) {
339
375
  logAndRethrow(error);
@@ -342,15 +378,9 @@ export default () => {
342
378
 
343
379
  disconnectedCallback() {
344
380
  try {
345
- if (this.#disposeOfEffect) {
346
- this.#disposeOfEffect();
347
- }
348
381
  if (this.#abortController) {
349
382
  this.#abortController.abort();
350
383
  }
351
- if (this.#elementMutationObserver) {
352
- this.#elementMutationObserver.disconnect();
353
- }
354
384
  } catch (error) {
355
385
  logAndRethrow(error);
356
386
  }
@@ -361,13 +391,18 @@ export default () => {
361
391
  const dialog = this.shadowRoot.querySelector('dialog');
362
392
  if (dialog) {
363
393
  dialog.showModal();
394
+ this.open = true;
364
395
  }
365
396
  }
366
397
  }
367
398
 
368
399
  #onDialogClick(event: MouseEvent) {
369
400
  const dialog = event.currentTarget as HTMLDialogElement;
370
- if (!dialog || typeof dialog.getBoundingClientRect !== 'function' || typeof dialog.close !== 'function') {
401
+ if (
402
+ !dialog ||
403
+ typeof dialog.getBoundingClientRect !== 'function' ||
404
+ typeof dialog.close !== 'function'
405
+ ) {
371
406
  return;
372
407
  }
373
408
  const rect = dialog.getBoundingClientRect();
@@ -1,9 +1,10 @@
1
- import type { AccentedDialog } from './accented-dialog';
2
- import type { Position } from '../types';
3
1
  import { effect } from '@preact/signals-core';
4
2
  import type { Signal } from '@preact/signals-core';
5
- import supportsAnchorPositioning from '../utils/supports-anchor-positioning.js';
6
- import logAndRethrow from '../log-and-rethrow.js';
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';
7
8
 
8
9
  export interface AccentedTrigger extends HTMLElement {
9
10
  element: Element | undefined;
@@ -12,11 +13,9 @@ export interface AccentedTrigger extends HTMLElement {
12
13
  visible: Signal<boolean> | undefined;
13
14
  }
14
15
 
15
- const triggerSize = 'max(32px, 2rem)';
16
-
17
16
  // We want Accented to not throw an error in Node, and use static imports,
18
17
  // so we can't export `class extends HTMLElement` because HTMLElement is not available in Node.
19
- export default (name: string) => {
18
+ export const getAccentedTrigger = (name: string) => {
20
19
  const template = document.createElement('template');
21
20
 
22
21
  // I initially tried creating a CSSStyelSheet object with styles instead of having a <style> element in the template,
@@ -27,21 +26,38 @@ export default (name: string) => {
27
26
  template.innerHTML = `
28
27
  <style>
29
28
  :host {
29
+ --ratio: 1.2;
30
+ --base-size: max(1rem, 16px);
30
31
  position: fixed !important;
32
+ inset-inline-start: anchor(self-start) !important;
31
33
  inset-inline-end: anchor(self-end) !important;
32
34
  inset-block-start: anchor(self-start) !important;
33
-
34
- position-visibility: anchors-visible !important;
35
+ inset-block-end: anchor(self-end) !important;
35
36
 
36
37
  /* Revert potential effects of white-space: pre; set on a trigger's ancestor. */
37
38
  white-space: normal !important;
39
+
40
+ pointer-events: none !important;
38
41
  }
39
42
 
40
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
+
41
52
  box-sizing: border-box;
42
- font-size: 1rem;
43
- inline-size: ${triggerSize};
44
- block-size: ${triggerSize};
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;
45
61
 
46
62
  /* Make it look better in forced-colors mode. */
47
63
  border: 2px solid transparent;
@@ -49,6 +65,10 @@ export default (name: string) => {
49
65
  background-color: var(--${name}-primary-color);
50
66
  color: var(--${name}-secondary-color);
51
67
 
68
+ padding: 0;
69
+
70
+ border-radius: calc(0.25 * var(--base-size));
71
+
52
72
  outline-offset: -4px;
53
73
  outline-color: currentColor;
54
74
  outline-width: 2px;
@@ -58,12 +78,13 @@ export default (name: string) => {
58
78
  outline-style: solid;
59
79
  }
60
80
 
81
+ /* We should probably be comfortable with showing these styles on non-hover devices. */
61
82
  &:hover:not(:focus-visible) {
62
83
  outline-style: dashed;
63
84
  }
64
85
  }
65
86
  </style>
66
- <button id="trigger" lang="en">⚠</button>
87
+ <button id="trigger" lang="en">á</button>
67
88
  `;
68
89
 
69
90
  return class extends HTMLElement implements AccentedTrigger {
@@ -75,6 +96,8 @@ export default (name: string) => {
75
96
 
76
97
  #disposeOfVisibilityEffect: (() => void) | undefined;
77
98
 
99
+ #elementMutationObserver: MutationObserver | undefined;
100
+
78
101
  element: Element | undefined;
79
102
 
80
103
  dialog: AccentedDialog | undefined;
@@ -84,8 +107,8 @@ export default (name: string) => {
84
107
  visible: Signal<boolean> | undefined;
85
108
 
86
109
  constructor() {
110
+ super();
87
111
  try {
88
- super();
89
112
  this.attachShadow({ mode: 'open' });
90
113
  const content = template.content.cloneNode(true);
91
114
  if (this.shadowRoot) {
@@ -104,50 +127,82 @@ export default (name: string) => {
104
127
  if (trigger && this.element) {
105
128
  trigger.ariaLabel = `Accessibility issues in ${this.element.nodeName.toLowerCase()}`;
106
129
  }
107
- this.#abortController = new AbortController();
108
- trigger?.addEventListener('click', (event) => {
130
+
131
+ this.#setTransform();
132
+
133
+ this.#elementMutationObserver = new MutationObserver(() => {
109
134
  try {
110
- event.preventDefault();
111
-
112
- // We append the dialog when the button is clicked,
113
- // and remove it from the DOM when the dialog is closed.
114
- // This gives us a performance improvement since Axe
115
- // scan time seems to depend on the number of elements in the DOM.
116
- if (this.dialog) {
117
- this.#dialogCloseAbortController = new AbortController();
118
- document.body.append(this.dialog);
119
- this.dialog.showModal();
120
- this.dialog.addEventListener('close', () => {
121
- try {
122
- this.dialog?.remove();
123
- this.#dialogCloseAbortController?.abort();
124
- } catch (error) {
125
- logAndRethrow(error);
126
- }
127
- }, { signal: this.#dialogCloseAbortController.signal });
128
- }
135
+ this.#setTransform();
129
136
  } catch (error) {
130
137
  logAndRethrow(error);
131
138
  }
132
- }, { signal: this.#abortController.signal });
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
+ );
133
187
 
134
188
  if (!supportsAnchorPositioning(window)) {
135
189
  this.#disposeOfPositionEffect = effect(() => {
136
190
  if (this.position && trigger) {
137
191
  const position = this.position.value;
138
- this.style.setProperty('top', `${position.blockStartTop}px`, 'important');
139
- if (position.direction === 'ltr') {
140
- this.style.setProperty('left', `calc(${position.inlineEndLeft}px - ${triggerSize})`, 'important');
141
- } else if (this.position.value.direction === 'rtl') {
142
- this.style.setProperty('left', `${position.inlineEndLeft}px`, 'important');
143
- }
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');
144
196
  }
145
197
  });
146
-
147
- this.#disposeOfVisibilityEffect = effect(() => {
148
- this.style.setProperty('visibility', this.visible?.value ? 'visible' : 'hidden', 'important');
149
- });
150
198
  }
199
+ this.#disposeOfVisibilityEffect = effect(() => {
200
+ this.style.setProperty(
201
+ 'visibility',
202
+ this.visible?.value ? 'visible' : 'hidden',
203
+ 'important',
204
+ );
205
+ });
151
206
  }
152
207
  } catch (error) {
153
208
  logAndRethrow(error);
@@ -159,7 +214,7 @@ export default (name: string) => {
159
214
  if (this.#abortController) {
160
215
  this.#abortController.abort();
161
216
  }
162
- if (this.#dialogCloseAbortController) {
217
+ if (this.#dialogCloseAbortController && !this.dialog?.open) {
163
218
  this.#dialogCloseAbortController.abort();
164
219
  this.dialog?.remove();
165
220
  }
@@ -171,9 +226,26 @@ export default (name: string) => {
171
226
  this.#disposeOfVisibilityEffect();
172
227
  this.#disposeOfVisibilityEffect = undefined;
173
228
  }
229
+ if (this.#elementMutationObserver) {
230
+ this.#elementMutationObserver.disconnect();
231
+ }
174
232
  } catch (error) {
175
233
  logAndRethrow(error);
176
234
  }
177
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
+ }
178
250
  };
179
251
  };