accented 0.0.0-20250303013509 → 0.0.0-20250424114613

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 (145) hide show
  1. package/NOTICE +14 -0
  2. package/README.md +10 -4
  3. package/dist/accented.d.ts +2 -2
  4. package/dist/accented.d.ts.map +1 -1
  5. package/dist/accented.js +10 -5
  6. package/dist/accented.js.map +1 -1
  7. package/dist/constants.d.ts +1 -0
  8. package/dist/constants.d.ts.map +1 -1
  9. package/dist/constants.js +1 -0
  10. package/dist/constants.js.map +1 -1
  11. package/dist/dom-updater.d.ts.map +1 -1
  12. package/dist/dom-updater.js +66 -25
  13. package/dist/dom-updater.js.map +1 -1
  14. package/dist/elements/accented-dialog.d.ts +11 -7
  15. package/dist/elements/accented-dialog.d.ts.map +1 -1
  16. package/dist/elements/accented-dialog.js +85 -86
  17. package/dist/elements/accented-dialog.js.map +1 -1
  18. package/dist/elements/accented-trigger.d.ts +9 -5
  19. package/dist/elements/accented-trigger.d.ts.map +1 -1
  20. package/dist/elements/accented-trigger.js +35 -11
  21. package/dist/elements/accented-trigger.js.map +1 -1
  22. package/dist/fullscreen-listener.d.ts +2 -0
  23. package/dist/fullscreen-listener.d.ts.map +1 -0
  24. package/dist/fullscreen-listener.js +18 -0
  25. package/dist/fullscreen-listener.js.map +1 -0
  26. package/dist/logger.d.ts.map +1 -1
  27. package/dist/logger.js +4 -1
  28. package/dist/logger.js.map +1 -1
  29. package/dist/scanner.d.ts +2 -2
  30. package/dist/scanner.d.ts.map +1 -1
  31. package/dist/scanner.js +33 -19
  32. package/dist/scanner.js.map +1 -1
  33. package/dist/state.d.ts +2 -1
  34. package/dist/state.d.ts.map +1 -1
  35. package/dist/state.js +3 -0
  36. package/dist/state.js.map +1 -1
  37. package/dist/task-queue.d.ts +2 -2
  38. package/dist/task-queue.d.ts.map +1 -1
  39. package/dist/task-queue.js +2 -1
  40. package/dist/task-queue.js.map +1 -1
  41. package/dist/types.d.ts +42 -8
  42. package/dist/types.d.ts.map +1 -1
  43. package/dist/types.js.map +1 -1
  44. package/dist/utils/are-elements-with-issues-equal.d.ts +3 -0
  45. package/dist/utils/are-elements-with-issues-equal.d.ts.map +1 -0
  46. package/dist/utils/are-elements-with-issues-equal.js +5 -0
  47. package/dist/utils/are-elements-with-issues-equal.js.map +1 -0
  48. package/dist/utils/containing-blocks.d.ts +3 -0
  49. package/dist/utils/containing-blocks.d.ts.map +1 -0
  50. package/dist/utils/containing-blocks.js +46 -0
  51. package/dist/utils/containing-blocks.js.map +1 -0
  52. package/dist/utils/contains.d.ts +2 -0
  53. package/dist/utils/contains.d.ts.map +1 -0
  54. package/dist/utils/contains.js +19 -0
  55. package/dist/utils/contains.js.map +1 -0
  56. package/dist/utils/deduplicate-nodes.d.ts +2 -0
  57. package/dist/utils/deduplicate-nodes.d.ts.map +1 -0
  58. package/dist/utils/deduplicate-nodes.js +5 -0
  59. package/dist/utils/deduplicate-nodes.js.map +1 -0
  60. package/dist/utils/dom-helpers.d.ts +9 -0
  61. package/dist/utils/dom-helpers.d.ts.map +1 -0
  62. package/dist/utils/dom-helpers.js +32 -0
  63. package/dist/utils/dom-helpers.js.map +1 -0
  64. package/dist/utils/ensure-non-empty.d.ts +2 -0
  65. package/dist/utils/ensure-non-empty.d.ts.map +1 -0
  66. package/dist/utils/ensure-non-empty.js +7 -0
  67. package/dist/utils/ensure-non-empty.js.map +1 -0
  68. package/dist/utils/get-element-position.d.ts +8 -0
  69. package/dist/utils/get-element-position.d.ts.map +1 -1
  70. package/dist/utils/get-element-position.js +27 -11
  71. package/dist/utils/get-element-position.js.map +1 -1
  72. package/dist/utils/get-parent.d.ts +2 -0
  73. package/dist/utils/get-parent.d.ts.map +1 -0
  74. package/dist/utils/get-parent.js +12 -0
  75. package/dist/utils/get-parent.js.map +1 -0
  76. package/dist/utils/get-scan-context.d.ts +3 -0
  77. package/dist/utils/get-scan-context.d.ts.map +1 -0
  78. package/dist/utils/get-scan-context.js +28 -0
  79. package/dist/utils/get-scan-context.js.map +1 -0
  80. package/dist/utils/get-scrollable-ancestors.d.ts +1 -1
  81. package/dist/utils/get-scrollable-ancestors.d.ts.map +1 -1
  82. package/dist/utils/get-scrollable-ancestors.js +6 -2
  83. package/dist/utils/get-scrollable-ancestors.js.map +1 -1
  84. package/dist/utils/is-node-in-scan-context.d.ts +3 -0
  85. package/dist/utils/is-node-in-scan-context.d.ts.map +1 -0
  86. package/dist/utils/is-node-in-scan-context.js +26 -0
  87. package/dist/utils/is-node-in-scan-context.js.map +1 -0
  88. package/dist/utils/normalize-context.d.ts +3 -0
  89. package/dist/utils/normalize-context.d.ts.map +1 -0
  90. package/dist/utils/normalize-context.js +57 -0
  91. package/dist/utils/normalize-context.js.map +1 -0
  92. package/dist/utils/shadow-dom-aware-mutation-observer.d.ts +10 -0
  93. package/dist/utils/shadow-dom-aware-mutation-observer.d.ts.map +1 -0
  94. package/dist/utils/shadow-dom-aware-mutation-observer.js +64 -0
  95. package/dist/utils/shadow-dom-aware-mutation-observer.js.map +1 -0
  96. package/dist/utils/transform-violations.d.ts +1 -1
  97. package/dist/utils/transform-violations.d.ts.map +1 -1
  98. package/dist/utils/transform-violations.js +18 -5
  99. package/dist/utils/transform-violations.js.map +1 -1
  100. package/dist/utils/update-elements-with-issues.d.ts +10 -4
  101. package/dist/utils/update-elements-with-issues.d.ts.map +1 -1
  102. package/dist/utils/update-elements-with-issues.js +33 -6
  103. package/dist/utils/update-elements-with-issues.js.map +1 -1
  104. package/dist/validate-options.d.ts.map +1 -1
  105. package/dist/validate-options.js +86 -0
  106. package/dist/validate-options.js.map +1 -1
  107. package/package.json +8 -3
  108. package/src/accented.ts +10 -5
  109. package/src/constants.ts +1 -0
  110. package/src/dom-updater.ts +70 -24
  111. package/src/elements/accented-dialog.ts +88 -90
  112. package/src/elements/accented-trigger.ts +36 -12
  113. package/src/fullscreen-listener.ts +17 -0
  114. package/src/logger.ts +9 -1
  115. package/src/scanner.ts +37 -20
  116. package/src/state.ts +10 -2
  117. package/src/task-queue.ts +6 -4
  118. package/src/types.ts +55 -9
  119. package/src/utils/are-elements-with-issues-equal.ts +9 -0
  120. package/src/utils/containing-blocks.ts +57 -0
  121. package/src/utils/contains.test.ts +55 -0
  122. package/src/utils/contains.ts +19 -0
  123. package/src/utils/deduplicate-nodes.ts +3 -0
  124. package/src/utils/dom-helpers.ts +38 -0
  125. package/src/utils/ensure-non-empty.ts +6 -0
  126. package/src/utils/get-element-position.ts +28 -11
  127. package/src/utils/get-parent.ts +14 -0
  128. package/src/utils/get-scan-context.test.ts +79 -0
  129. package/src/utils/get-scan-context.ts +39 -0
  130. package/src/utils/get-scrollable-ancestors.ts +10 -5
  131. package/src/utils/is-node-in-scan-context.test.ts +70 -0
  132. package/src/utils/is-node-in-scan-context.ts +29 -0
  133. package/src/utils/normalize-context.test.ts +105 -0
  134. package/src/utils/normalize-context.ts +58 -0
  135. package/src/utils/shadow-dom-aware-mutation-observer.ts +78 -0
  136. package/src/utils/transform-violations.test.ts +10 -8
  137. package/src/utils/transform-violations.ts +20 -6
  138. package/src/utils/update-elements-with-issues.test.ts +102 -15
  139. package/src/utils/update-elements-with-issues.ts +51 -7
  140. package/src/validate-options.ts +88 -1
  141. package/dist/utils/is-html-element.d.ts +0 -2
  142. package/dist/utils/is-html-element.d.ts.map +0 -1
  143. package/dist/utils/is-html-element.js +0 -7
  144. package/dist/utils/is-html-element.js.map +0 -1
  145. package/src/utils/is-html-element.ts +0 -6
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "accented",
3
- "version": "0.0.0-20250303013509",
3
+ "version": "0.0.0-20250424114613",
4
4
  "description": "Continuous accessibility testing and issue highlighting for web development",
5
5
  "type": "module",
6
6
  "main": "dist/accented.js",
7
7
  "files": [
8
8
  "dist",
9
- "src"
9
+ "src",
10
+ "NOTICE"
10
11
  ],
11
12
  "repository": {
12
13
  "type": "git",
@@ -26,7 +27,11 @@
26
27
  "homepage": "https://github.com/pomerantsev/accented#readme",
27
28
  "dependencies": {
28
29
  "@preact/signals-core": "^1.8.0",
29
- "axe-core": "^4.10.2"
30
+ "axe-core": "^4.10.3"
31
+ },
32
+ "devDependencies": {
33
+ "@types/jsdom": "^21.1.7",
34
+ "jsdom": "^26.0.0"
30
35
  },
31
36
  "scripts": {
32
37
  "build": "tsc",
package/src/accented.ts CHANGED
@@ -5,6 +5,7 @@ import createLogger from './logger.js';
5
5
  import createScanner from './scanner.js';
6
6
  import setupScrollListeners from './scroll-listeners.js';
7
7
  import setupResizeListener from './resize-listener.js';
8
+ import setupFullscreenListener from './fullscreen-listener.js';
8
9
  import setupIntersectionObserver from './intersection-observer.js';
9
10
  import { enabled, extendedElementsWithIssues } from './state.js';
10
11
  import deepMerge from './utils/deep-merge.js';
@@ -12,6 +13,7 @@ import type { AccentedOptions, DisableAccented } from './types';
12
13
  import validateOptions from './validate-options.js';
13
14
  import supportsAnchorPositioning from './utils/supports-anchor-positioning.js';
14
15
  import logAndRethrow from './log-and-rethrow.js';
16
+ import { initializeContainingBlockSupportSet } from './utils/containing-blocks.js';
15
17
 
16
18
  export type { AccentedOptions, DisableAccented };
17
19
 
@@ -34,9 +36,9 @@ export type { AccentedOptions, DisableAccented };
34
36
  * wait: 500,
35
37
  * leading: false
36
38
  * },
37
- * callback: ({ elementsWithIssues, scanDuration }) => {
39
+ * callback: ({ elementsWithIssues, performance }) => {
38
40
  * console.log('Elements with issues:', elementsWithIssues);
39
- * console.log('Scan duration:', scanDuration);
41
+ * console.log('Total blocking time:', performance.totalBlockingTime);
40
42
  * }
41
43
  * });
42
44
  */
@@ -66,7 +68,7 @@ export default function accented(options: AccentedOptions = {}): DisableAccented
66
68
  // * update examples in the accented() function JSDoc;
67
69
  // * update examples in the Readme.
68
70
  const defaultOptions: Required<AccentedOptions> = {
69
- axeContext: document,
71
+ context: document,
70
72
  axeOptions: {},
71
73
  name: 'accented',
72
74
  output: defaultOutput,
@@ -74,7 +76,7 @@ export default function accented(options: AccentedOptions = {}): DisableAccented
74
76
  callback: () => {}
75
77
  };
76
78
 
77
- const {axeContext, axeOptions, name, output, throttle, callback} = deepMerge(defaultOptions, options);
79
+ const {context, axeOptions, name, output, throttle, callback} = deepMerge(defaultOptions, options);
78
80
 
79
81
  if (enabled.value) {
80
82
  // Add link to the recipes section of the docs (#56).
@@ -87,14 +89,16 @@ export default function accented(options: AccentedOptions = {}): DisableAccented
87
89
 
88
90
  enabled.value = true;
89
91
 
92
+ initializeContainingBlockSupportSet();
90
93
  registerElements(name);
91
94
 
92
95
  const {disconnect: cleanupIntersectionObserver, intersectionObserver } = supportsAnchorPositioning(window) ? {} : setupIntersectionObserver();
93
- const cleanupScanner = createScanner(name, axeContext, axeOptions, throttle, callback);
96
+ const cleanupScanner = createScanner(name, context, axeOptions, throttle, callback);
94
97
  const cleanupDomUpdater = createDomUpdater(name, intersectionObserver);
95
98
  const cleanupLogger = output.console ? createLogger() : () => {};
96
99
  const cleanupScrollListeners = supportsAnchorPositioning(window) ? () => {} : setupScrollListeners();
97
100
  const cleanupResizeListener = supportsAnchorPositioning(window) ? () => {} : setupResizeListener();
101
+ const cleanupFullscreenListener = supportsAnchorPositioning(window) ? () => {} : setupFullscreenListener();
98
102
 
99
103
  return () => {
100
104
  try {
@@ -105,6 +109,7 @@ export default function accented(options: AccentedOptions = {}): DisableAccented
105
109
  cleanupLogger();
106
110
  cleanupScrollListeners();
107
111
  cleanupResizeListener();
112
+ cleanupFullscreenListener();
108
113
  if (cleanupIntersectionObserver) {
109
114
  cleanupIntersectionObserver();
110
115
  }
package/src/constants.ts CHANGED
@@ -1,2 +1,3 @@
1
1
  export const accentedUrl = 'https://www.npmjs.com/package/accented';
2
2
  export const issuesUrl = 'https://github.com/pomerantsev/accented/issues';
3
+ export const getAccentedElementNames = (name: string) => [`${name}-trigger`, `${name}-dialog`];
@@ -1,7 +1,34 @@
1
1
  import { effect } from '@preact/signals-core';
2
- import { extendedElementsWithIssues } from './state.js';
2
+ import { extendedElementsWithIssues, rootNodes } from './state.js';
3
3
  import type { ExtendedElementWithIssues } from './types';
4
+ import areElementsWithIssuesEqual from './utils/are-elements-with-issues-equal.js';
4
5
  import supportsAnchorPositioning from './utils/supports-anchor-positioning.js';
6
+ import { isDocument, isDocumentFragment, isShadowRoot } from './utils/dom-helpers.js';
7
+ import getParent from './utils/get-parent.js';
8
+
9
+ const shouldInsertTriggerInsideElement = (element: Element): boolean => {
10
+ /**
11
+ * No parent means that the element is a root node,
12
+ * which cannot have siblings.
13
+ */
14
+ const noParent = !getParent(element);
15
+
16
+ /**
17
+ * Table cells get a special treatment because if a sibling to a TH or TD is inserted,
18
+ * it alters the table layout, no matter how that sibling is positioned.
19
+ * We don't want tables to look broken, so we're inserting the trigger inside the table cell.
20
+ */
21
+ const isTableCell = element.nodeName === 'TH' || element.nodeName === 'TD';
22
+
23
+ /**
24
+ * We want to put the trigger inside the <summary> element,
25
+ * because otherwise it will be hidden by the browser when the <details> element is collapsed
26
+ * (since none of the siblings of <summary> are visible then).
27
+ */
28
+ const isSummary = element.nodeName === 'SUMMARY';
29
+
30
+ return noParent || isTableCell || isSummary;
31
+ };
5
32
 
6
33
  export default function createDomUpdater(name: string, intersectionObserver?: IntersectionObserver) {
7
34
  const attrName = `data-${name}`;
@@ -13,8 +40,8 @@ export default function createDomUpdater(name: string, intersectionObserver?: In
13
40
  .filter(anchorName => anchorName.startsWith('--'));
14
41
  }
15
42
 
16
- function setAnchorName (element: HTMLElement, id: number) {
17
- const anchorNameValue = element.style.getPropertyValue('anchor-name') || window.getComputedStyle(element).getPropertyValue('anchor-name');
43
+ function setAnchorName (elementWithIssues: ExtendedElementWithIssues) {
44
+ const { element, id, anchorNameValue } = elementWithIssues;
18
45
  const anchorNames = getAnchorNames(anchorNameValue);
19
46
  if (anchorNames.length > 0) {
20
47
  element.style.setProperty('anchor-name', `${anchorNameValue}, --${name}-anchor-${id}`);
@@ -23,28 +50,30 @@ export default function createDomUpdater(name: string, intersectionObserver?: In
23
50
  }
24
51
  }
25
52
 
26
- function removeAnchorName (element: HTMLElement, id: number) {
27
- const anchorNameValue = element.style.getPropertyValue('anchor-name');
53
+ function removeAnchorName (elementWithIssues: ExtendedElementWithIssues) {
54
+ const { element, anchorNameValue } = elementWithIssues;
28
55
  const anchorNames = getAnchorNames(anchorNameValue);
29
- const index = anchorNames.indexOf(`--${name}-anchor-${id}`);
30
- if (anchorNames.length === 1 && index === 0) {
56
+ if (anchorNames.length > 0) {
57
+ element.style.setProperty('anchor-name', anchorNames.join(', '));
58
+ } else {
31
59
  element.style.removeProperty('anchor-name');
32
- } else if (anchorNames.length > 1 && index > -1) {
33
- element.style.setProperty('anchor-name', anchorNames.filter((_, i) => i !== index).join(', '));
34
60
  }
35
61
  }
36
62
 
37
63
  function setIssues (extendedElementsWithIssues: Array<ExtendedElementWithIssues>) {
38
64
  for (const elementWithIssues of extendedElementsWithIssues) {
65
+ if (elementWithIssues.skipRender) {
66
+ continue;
67
+ }
39
68
  elementWithIssues.element.setAttribute(attrName, elementWithIssues.id.toString());
40
69
  if (supportsAnchorPositioning(window)) {
41
- setAnchorName(elementWithIssues.element, elementWithIssues.id);
70
+ setAnchorName(elementWithIssues);
42
71
  }
43
72
 
44
- if (elementWithIssues.element.parentElement) {
45
- elementWithIssues.element.insertAdjacentElement('afterend', elementWithIssues.trigger);
46
- } else {
73
+ if (shouldInsertTriggerInsideElement(elementWithIssues.element)) {
47
74
  elementWithIssues.element.insertAdjacentElement('beforeend', elementWithIssues.trigger);
75
+ } else {
76
+ elementWithIssues.element.insertAdjacentElement('afterend', elementWithIssues.trigger);
48
77
  }
49
78
  if (intersectionObserver) {
50
79
  intersectionObserver.observe(elementWithIssues.element);
@@ -54,9 +83,12 @@ export default function createDomUpdater(name: string, intersectionObserver?: In
54
83
 
55
84
  function removeIssues (extendedElementsWithIssues: Array<ExtendedElementWithIssues>) {
56
85
  for (const elementWithIssues of extendedElementsWithIssues) {
86
+ if (elementWithIssues.skipRender) {
87
+ continue;
88
+ }
57
89
  elementWithIssues.element.removeAttribute(attrName);
58
90
  if (supportsAnchorPositioning(window)) {
59
- removeAnchorName(elementWithIssues.element, elementWithIssues.id);
91
+ removeAnchorName(elementWithIssues);
60
92
  }
61
93
  elementWithIssues.trigger.remove();
62
94
  if (intersectionObserver) {
@@ -69,8 +101,10 @@ export default function createDomUpdater(name: string, intersectionObserver?: In
69
101
  stylesheet.replaceSync(`
70
102
  @layer ${name} {
71
103
  :root {
72
- --${name}-primary-color: red;
73
- --${name}-secondary-color: white;
104
+ /* Ensure that the primary / secondary color combination meets WCAG 1.4.3 Contrast (Minimum) */
105
+ /* OKLCH stuff: https://oklch.com/ */
106
+ --${name}-primary-color: oklch(0.5 0.3 0);
107
+ --${name}-secondary-color: oklch(0.98 0 0);
74
108
  --${name}-outline-width: 2px;
75
109
  --${name}-outline-style: solid;
76
110
  }
@@ -86,19 +120,31 @@ export default function createDomUpdater(name: string, intersectionObserver?: In
86
120
 
87
121
  let previousExtendedElementsWithIssues: Array<ExtendedElementWithIssues> = [];
88
122
 
89
- document.adoptedStyleSheets.push(stylesheet);
90
- const removeStylesheet = () => {
91
- if (document.adoptedStyleSheets.includes(stylesheet)) {
92
- document.adoptedStyleSheets.splice(document.adoptedStyleSheets.indexOf(stylesheet), 1);
123
+ let previousRootNodes: Set<Node> = new Set();
124
+
125
+ const disposeOfStyleSheetsEffect = effect(() => {
126
+ const newRootNodes = rootNodes.value;
127
+ const addedRootNodes = [...newRootNodes].filter(rootNode => !previousRootNodes.has(rootNode));
128
+ const removedRootNodes = [...previousRootNodes].filter(rootNode => !newRootNodes.has(rootNode));
129
+ for (const rootNode of addedRootNodes) {
130
+ if (isDocument(rootNode) || (isDocumentFragment(rootNode) && isShadowRoot(rootNode))) {
131
+ rootNode.adoptedStyleSheets.push(stylesheet);
132
+ }
93
133
  }
94
- };
134
+ for (const rootNode of removedRootNodes) {
135
+ if (isDocument(rootNode) || (isDocumentFragment(rootNode) && isShadowRoot(rootNode))) {
136
+ rootNode.adoptedStyleSheets.splice(rootNode.adoptedStyleSheets.indexOf(stylesheet), 1);
137
+ }
138
+ }
139
+ previousRootNodes = newRootNodes;
140
+ });
95
141
 
96
142
  const disposeOfElementsEffect = effect(() => {
97
143
  const added = extendedElementsWithIssues.value.filter(elementWithIssues => {
98
- return !previousExtendedElementsWithIssues.some(previousElementWithIssues => previousElementWithIssues.element === elementWithIssues.element);
144
+ return !previousExtendedElementsWithIssues.some(previousElementWithIssues => areElementsWithIssuesEqual(previousElementWithIssues, elementWithIssues));
99
145
  });
100
146
  const removed = previousExtendedElementsWithIssues.filter(previousElementWithIssues => {
101
- return !extendedElementsWithIssues.value.some(elementWithIssues => elementWithIssues.element === previousElementWithIssues.element);
147
+ return !extendedElementsWithIssues.value.some(elementWithIssues => areElementsWithIssuesEqual(elementWithIssues, previousElementWithIssues));
102
148
  });
103
149
  removeIssues(removed);
104
150
  setIssues(added);
@@ -106,7 +152,7 @@ export default function createDomUpdater(name: string, intersectionObserver?: In
106
152
  });
107
153
 
108
154
  return () => {
109
- removeStylesheet();
155
+ disposeOfStyleSheetsEffect();
110
156
  disposeOfElementsEffect();
111
157
  };
112
158
  }
@@ -1,6 +1,5 @@
1
1
  import type { Issue } from '../types';
2
2
  import type { Signal } from '@preact/signals-core';
3
- import { effect } from '@preact/signals-core';
4
3
  import getElementHtml from '../utils/get-element-html.js';
5
4
  import { accentedUrl } from '../constants.js';
6
5
  import logAndRethrow from '../log-and-rethrow.js';
@@ -9,6 +8,7 @@ export interface AccentedDialog extends HTMLElement {
9
8
  issues: Signal<Array<Issue>> | undefined;
10
9
  element: Element | undefined;
11
10
  showModal: () => void;
11
+ open: boolean;
12
12
  }
13
13
 
14
14
  // We want Accented to not throw an error in Node, and use static imports,
@@ -56,20 +56,45 @@ export default () => {
56
56
  :host {
57
57
  all: initial !important;
58
58
 
59
- --light-color: white;
60
- --dark-color: black;
61
- --focus-color: #0078d4; /* Contrasts with both white and black. */
59
+ /* OKLCH stuff: https://oklch.com/ */
60
+ --light-color: oklch(0.98 0 0);
61
+ --dark-color: oklch(0.22 0 0);
62
62
 
63
- --impact-minor-color: lightgray;
64
- --impact-moderate-color: gold;
65
- --impact-serious-color: #ff9e00;
66
- --impact-critical-color: #f883ec;
63
+ --background-color: light-dark(var(--light-color), var(--dark-color));
64
+ --text-color: light-dark(var(--dark-color), var(--light-color));
65
+
66
+ --impact-lightness: 0.80;
67
+ --focus-lightness: 0.45;
68
+ @media (prefers-color-scheme: dark) {
69
+ --impact-lightness: 0.45;
70
+ --focus-lightness: 0.80;
71
+ }
72
+
73
+ --blue-hue: 230;
74
+ --gold-hue: 90;
75
+ --red-hue: 0;
76
+
77
+ /* Contrasts with background. */
78
+ --focus-color: oklch(var(--focus-lightness) 0.25 var(--blue-hue));
79
+
80
+ --impact-chroma: 0.16;
81
+
82
+ --impact-moderate-hue: var(--blue-hue);
83
+ --impact-serious-hue: var(--gold-hue);
84
+ --impact-critical-hue: var(--red-hue);
85
+
86
+ --impact-minor-color: oklch(var(--impact-lightness) 0 0);
87
+ --impact-moderate-color: oklch(var(--impact-lightness) var(--impact-chroma) var(--impact-moderate-hue));
88
+ --impact-serious-color: oklch(var(--impact-lightness) var(--impact-chroma) var(--impact-serious-hue));
89
+ --impact-critical-color: oklch(var(--impact-lightness) var(--impact-chroma) var(--impact-critical-hue));
90
+
91
+ --base-size: max(1rem, 16px);
67
92
 
68
93
  /* Spacing and typography custom props, inspired by https://utopia.fyi (simplified). */
69
94
 
70
95
  /* @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
96
  --ratio: 1.2;
72
- --step-0: 1rem;
97
+ --step-0: var(--base-size);
73
98
  --step-1: calc(var(--step-0) * var(--ratio));
74
99
  --step-2: calc(var(--step-1) * var(--ratio));
75
100
  --step-3: calc(var(--step-2) * var(--ratio));
@@ -77,15 +102,15 @@ export default () => {
77
102
  --step--1: calc(var(--step-0) / var(--ratio));
78
103
 
79
104
  /* @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;
105
+ --space-3xs: calc(0.25 * var(--base-size));
106
+ --space-2xs: calc(0.5 * var(--base-size));
107
+ --space-xs: calc(0.75 * var(--base-size));
108
+ --space-s: var(--base-size);
109
+ --space-m: calc(1.5 * var(--base-size));
110
+ --space-l: calc(2 * var(--base-size));
111
+ --space-xl: calc(3 * var(--base-size));
112
+ --space-2xl: calc(4 * var(--base-size));
113
+ --space-3xl: calc(6 * var(--base-size));
89
114
  }
90
115
 
91
116
  a[href], button {
@@ -116,12 +141,15 @@ export default () => {
116
141
  overflow-wrap: break-word;
117
142
  font-family: system-ui;
118
143
  line-height: 1.5;
119
- background-color: var(--light-color);
120
- color: var(--dark-color);
144
+ text-wrap: pretty;
145
+ background-color: var(--background-color);
146
+ color: var(--text-color);
121
147
  border: 2px solid currentColor;
122
148
  padding: var(--space-l);
123
149
  inline-size: min(90ch, calc(100% - var(--space-s)* 2));
124
150
  max-block-size: calc(100% - var(--space-s) * 2);
151
+
152
+ color-scheme: light dark;
125
153
  }
126
154
 
127
155
  #button-container {
@@ -129,8 +157,8 @@ export default () => {
129
157
  }
130
158
 
131
159
  #close {
132
- background-color: var(--light-color);
133
- color: var(--dark-color);
160
+ background-color: var(--background-color);
161
+ color: var(--text-color);
134
162
  border: 2px solid currentColor;
135
163
  padding-inline: var(--space-2xs);
136
164
  aspect-ratio: 1 / 1;
@@ -167,7 +195,7 @@ export default () => {
167
195
  }
168
196
 
169
197
  a {
170
- font-weight: bold;
198
+ font-weight: 500;
171
199
  }
172
200
  }
173
201
 
@@ -213,15 +241,13 @@ export default () => {
213
241
  `);
214
242
 
215
243
  return class extends HTMLElement implements AccentedDialog {
216
- #disposeOfEffect: (() => void) | undefined;
217
-
218
244
  #abortController: AbortController | undefined;
219
245
 
220
246
  issues: Signal<Array<Issue>> | undefined;
221
247
 
222
248
  element: Element | undefined;
223
249
 
224
- #elementMutationObserver: MutationObserver | undefined;
250
+ open: boolean = false;
225
251
 
226
252
  constructor() {
227
253
  try {
@@ -270,75 +296,52 @@ export default () => {
270
296
  }
271
297
  }, { signal: this.#abortController.signal });
272
298
 
273
- this.#disposeOfEffect = effect(() => {
274
- if (this.issues) {
275
- const issues = this.issues.value;
276
- const issuesList = shadowRoot.getElementById('issues');
277
- if (issuesList) {
278
- issuesList.innerHTML = '';
279
- for (const issue of issues) {
280
- const issueContent = issueTemplate.content.cloneNode(true) as Element;
281
- const title = issueContent.querySelector('a');
282
- const impact = issueContent.querySelector('.impact');
283
- const description = issueContent.querySelector('.description');
284
- if (title && impact && description) {
285
- title.textContent = issue.title + ' (' + issue.id + ')';
286
- title.href = issue.url;
287
-
288
- impact.textContent = 'User impact: ' + issue.impact;
289
- impact.setAttribute('data-impact', String(issue.impact));
290
-
291
- const descriptionItems = issue.description.split(/\n\s*/);
292
- const descriptionContent = descriptionTemplate.content.cloneNode(true) as Element;
293
- const descriptionTitle = descriptionContent.querySelector('span');
294
- const descriptionList = descriptionContent.querySelector('ul');
295
- if (descriptionTitle && descriptionList && descriptionItems.length > 1) {
296
- descriptionTitle.textContent = descriptionItems[0]!;
297
- for (const descriptionItem of descriptionItems.slice(1)) {
298
- const li = document.createElement('li');
299
- li.textContent = descriptionItem;
300
- descriptionList.appendChild(li);
301
- }
302
- description.appendChild(descriptionContent);
299
+ if (this.issues) {
300
+ const issues = this.issues.value;
301
+ const issuesList = shadowRoot.getElementById('issues');
302
+ if (issuesList) {
303
+ issuesList.innerHTML = '';
304
+ for (const issue of issues) {
305
+ const issueContent = issueTemplate.content.cloneNode(true) as Element;
306
+ const title = issueContent.querySelector('a');
307
+ const impact = issueContent.querySelector('.impact');
308
+ const description = issueContent.querySelector('.description');
309
+ if (title && impact && description) {
310
+ title.textContent = issue.title + ' (' + issue.id + ')';
311
+ title.href = issue.url;
312
+
313
+ impact.textContent = 'User impact: ' + issue.impact;
314
+ impact.setAttribute('data-impact', String(issue.impact));
315
+
316
+ const descriptionItems = issue.description.split(/\n\s*/);
317
+ const descriptionContent = descriptionTemplate.content.cloneNode(true) as Element;
318
+ const descriptionTitle = descriptionContent.querySelector('span');
319
+ const descriptionList = descriptionContent.querySelector('ul');
320
+ if (descriptionTitle && descriptionList && descriptionItems.length > 1) {
321
+ descriptionTitle.textContent = descriptionItems[0]!;
322
+ for (const descriptionItem of descriptionItems.slice(1)) {
323
+ const li = document.createElement('li');
324
+ li.textContent = descriptionItem;
325
+ descriptionList.appendChild(li);
303
326
  }
327
+ description.appendChild(descriptionContent);
304
328
  }
305
- issuesList.appendChild(issueContent);
306
329
  }
330
+ issuesList.appendChild(issueContent);
307
331
  }
308
332
  }
309
- });
310
-
311
- const updateElementHtml = () => {
312
- if (this.element) {
313
- const elementHtmlContainer = shadowRoot.getElementById('element-html');
314
- if (elementHtmlContainer) {
315
- elementHtmlContainer.textContent = getElementHtml(this.element);
316
- }
317
- }
318
- };
319
-
320
- updateElementHtml();
333
+ }
321
334
 
322
- this.#elementMutationObserver = new MutationObserver(() => {
323
- try {
324
- updateElementHtml();
325
- } catch (error) {
326
- logAndRethrow(error);
327
- }
328
- });
329
335
  if (this.element) {
330
- // We're only outputting the element itself, not its subtree.
331
- // However, we're still listening for childList changes, because
332
- // we display an ellipsis if the element has innerHTML,
333
- // and we leave it empty if the element is empty.
334
- this.#elementMutationObserver.observe(this.element, {
335
- attributes: true,
336
- childList: true
337
- });
336
+ const elementHtmlContainer = shadowRoot.getElementById('element-html');
337
+ if (elementHtmlContainer) {
338
+ elementHtmlContainer.textContent = getElementHtml(this.element);
339
+ }
338
340
  }
339
341
 
340
342
  dialog?.addEventListener('close', () => {
341
343
  try {
344
+ this.open = false;
342
345
  this.dispatchEvent(new Event('close'));
343
346
  } catch (error) {
344
347
  logAndRethrow(error);
@@ -352,15 +355,9 @@ export default () => {
352
355
 
353
356
  disconnectedCallback() {
354
357
  try {
355
- if (this.#disposeOfEffect) {
356
- this.#disposeOfEffect();
357
- }
358
358
  if (this.#abortController) {
359
359
  this.#abortController.abort();
360
360
  }
361
- if (this.#elementMutationObserver) {
362
- this.#elementMutationObserver.disconnect();
363
- }
364
361
  } catch (error) {
365
362
  logAndRethrow(error);
366
363
  }
@@ -371,6 +368,7 @@ export default () => {
371
368
  const dialog = this.shadowRoot.querySelector('dialog');
372
369
  if (dialog) {
373
370
  dialog.showModal();
371
+ this.open = true;
374
372
  }
375
373
  }
376
374
  }
@@ -12,8 +12,6 @@ export interface AccentedTrigger extends HTMLElement {
12
12
  visible: Signal<boolean> | undefined;
13
13
  }
14
14
 
15
- const triggerSize = 'max(32px, 2rem)';
16
-
17
15
  // We want Accented to not throw an error in Node, and use static imports,
18
16
  // so we can't export `class extends HTMLElement` because HTMLElement is not available in Node.
19
17
  export default (name: string) => {
@@ -27,6 +25,8 @@ export default (name: string) => {
27
25
  template.innerHTML = `
28
26
  <style>
29
27
  :host {
28
+ --ratio: 1.2;
29
+ --base-size: max(1rem, 16px);
30
30
  position: fixed !important;
31
31
  inset-inline-start: anchor(self-start) !important;
32
32
  inset-inline-end: anchor(self-end) !important;
@@ -44,13 +44,19 @@ export default (name: string) => {
44
44
  #trigger {
45
45
  pointer-events: auto;
46
46
 
47
- position: absolute;
48
- inset-inline-end: 0;
47
+ margin-inline-start: auto;
48
+ margin-inline-end: 4px;
49
+ margin-block-start: 4px;
49
50
 
50
51
  box-sizing: border-box;
51
- font-size: 1rem;
52
- inline-size: ${triggerSize};
53
- block-size: ${triggerSize};
52
+ font-family: system-ui;
53
+ font-size: calc(var(--ratio) * var(--ratio) * var(--base-size));
54
+ inline-size: calc(2 * var(--base-size));
55
+ block-size: calc(2 * var(--base-size));
56
+
57
+ display: flex;
58
+ align-items: center;
59
+ justify-content: center;
54
60
 
55
61
  /* Make it look better in forced-colors mode. */
56
62
  border: 2px solid transparent;
@@ -58,6 +64,10 @@ export default (name: string) => {
58
64
  background-color: var(--${name}-primary-color);
59
65
  color: var(--${name}-secondary-color);
60
66
 
67
+ padding: 0;
68
+
69
+ border-radius: calc(0.25 * var(--base-size));
70
+
61
71
  outline-offset: -4px;
62
72
  outline-color: currentColor;
63
73
  outline-width: 2px;
@@ -72,7 +82,7 @@ export default (name: string) => {
72
82
  }
73
83
  }
74
84
  </style>
75
- <button id="trigger" lang="en">⚠</button>
85
+ <button id="trigger" lang="en">á</button>
76
86
  `;
77
87
 
78
88
  return class extends HTMLElement implements AccentedTrigger {
@@ -135,8 +145,14 @@ export default (name: string) => {
135
145
  this.#abortController = new AbortController();
136
146
  trigger?.addEventListener('click', (event) => {
137
147
  try {
148
+ // event.preventDefault() ensures that if the issue is within a link,
149
+ // the link's default behavior (following the URL) is prevented.
138
150
  event.preventDefault();
139
151
 
152
+ // event.stopPropagation() ensures that if there's a click handler on the trigger's ancestor
153
+ // (a link, or a button, or anything else), it doesn't get triggered.
154
+ event.stopPropagation();
155
+
140
156
  // We append the dialog when the button is clicked,
141
157
  // and remove it from the DOM when the dialog is closed.
142
158
  // This gives us a performance improvement since Axe
@@ -185,7 +201,7 @@ export default (name: string) => {
185
201
  if (this.#abortController) {
186
202
  this.#abortController.abort();
187
203
  }
188
- if (this.#dialogCloseAbortController) {
204
+ if (this.#dialogCloseAbortController && !this.dialog?.open) {
189
205
  this.#dialogCloseAbortController.abort();
190
206
  this.dialog?.remove();
191
207
  }
@@ -206,9 +222,17 @@ export default (name: string) => {
206
222
  }
207
223
 
208
224
  #setTransform() {
209
- if (this.element) {
210
- this.style.setProperty('transform', window.getComputedStyle(this.element).getPropertyValue('transform'), 'important');
211
- }
225
+ // We read and write values in separate animation frames to avoid layout thrashing.
226
+ window.requestAnimationFrame(() => {
227
+ if (this.element) {
228
+ const transform = window.getComputedStyle(this.element).getPropertyValue('transform');
229
+ if (transform !== 'none') {
230
+ window.requestAnimationFrame(() => {
231
+ this.style.setProperty('transform', transform, 'important');
232
+ });
233
+ }
234
+ }
235
+ });
212
236
  }
213
237
  };
214
238
  };