accented 1.2.6 → 1.3.1

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 (34) hide show
  1. package/dist/constants.d.ts +12 -0
  2. package/dist/constants.d.ts.map +1 -1
  3. package/dist/constants.js +32 -0
  4. package/dist/constants.js.map +1 -1
  5. package/dist/elements/accented-trigger.js +1 -1
  6. package/dist/elements/accented-trigger.js.map +1 -1
  7. package/dist/scanner.d.ts.map +1 -1
  8. package/dist/scanner.js +79 -41
  9. package/dist/scanner.js.map +1 -1
  10. package/dist/utils/create-extended-element-with-issues.d.ts +3 -0
  11. package/dist/utils/create-extended-element-with-issues.d.ts.map +1 -0
  12. package/dist/utils/create-extended-element-with-issues.js +56 -0
  13. package/dist/utils/create-extended-element-with-issues.js.map +1 -0
  14. package/dist/utils/get-all-rules-from-axe-options.d.ts +3 -0
  15. package/dist/utils/get-all-rules-from-axe-options.d.ts.map +1 -0
  16. package/dist/utils/get-all-rules-from-axe-options.js +51 -0
  17. package/dist/utils/get-all-rules-from-axe-options.js.map +1 -0
  18. package/dist/utils/transform-violations.d.ts.map +1 -1
  19. package/dist/utils/transform-violations.js +2 -13
  20. package/dist/utils/transform-violations.js.map +1 -1
  21. package/dist/utils/update-elements-with-issues.d.ts +4 -3
  22. package/dist/utils/update-elements-with-issues.d.ts.map +1 -1
  23. package/dist/utils/update-elements-with-issues.js +36 -80
  24. package/dist/utils/update-elements-with-issues.js.map +1 -1
  25. package/package.json +5 -5
  26. package/src/constants.ts +34 -0
  27. package/src/elements/accented-trigger.ts +1 -1
  28. package/src/scanner.ts +91 -45
  29. package/src/utils/create-extended-element-with-issues.ts +67 -0
  30. package/src/utils/get-all-rules-from-axe-options.test.ts +169 -0
  31. package/src/utils/get-all-rules-from-axe-options.ts +54 -0
  32. package/src/utils/transform-violations.ts +2 -14
  33. package/src/utils/update-elements-with-issues.test.ts +223 -139
  34. package/src/utils/update-elements-with-issues.ts +76 -107
@@ -1,93 +1,49 @@
1
- import { batch, signal } from '@preact/signals-core';
1
+ import { batch } from '@preact/signals-core';
2
+ import { descendantDependentRules } from '../constants.js';
2
3
  import { areElementsWithIssuesEqual } from './are-elements-with-issues-equal.js';
3
4
  import { areIssueSetsEqual } from './are-issue-sets-equal.js';
4
- import { isSvgElement } from './dom-helpers.js';
5
- import { getElementPosition } from './get-element-position.js';
6
- import { getParent } from './get-parent.js';
7
- import { getScrollableAncestors } from './get-scrollable-ancestors.js';
5
+ import { createExtendedElementWithIssues } from './create-extended-element-with-issues.js';
8
6
  import { isNodeInScanContext } from './is-node-in-scan-context.js';
9
- import { supportsAnchorPositioning } from './supports-anchor-positioning.js';
10
7
  import { transformViolations } from './transform-violations.js';
11
- function shouldSkipRender(element) {
12
- // Skip rendering if the element is inside an SVG:
13
- // https://github.com/pomerantsev/accented/issues/62
14
- const parent = getParent(element);
15
- const isInsideSvg = Boolean(parent && isSvgElement(parent));
16
- // Some issues, such as meta-viewport, are on <head> descendants,
17
- // but since <head> is never rendered, we don't want to output anything
18
- // for those in the DOM.
19
- // We're not anticipating the use of shadow DOM in <head>,
20
- // so the use of .closest() should be fine.
21
- const isInsideHead = element.closest('head') !== null;
22
- return isInsideSvg || isInsideHead;
8
+ function getIssuesForElement(element, list) {
9
+ return list.find((entry) => areElementsWithIssuesEqual(entry, element))?.issues ?? [];
23
10
  }
24
- let count = 0;
25
- export function updateElementsWithIssues({ extendedElementsWithIssues, scanContext, violations, name, }) {
26
- const updatedElementsWithIssues = transformViolations(violations, name);
11
+ function mergeLimitedContextAndFullContextViolations(elementsFromLimitedContext, elementsFromFullContext) {
12
+ const fromLimitedWithFullIssuesMerged = elementsFromLimitedContext.map((limited) => {
13
+ const fullMatch = elementsFromFullContext.find((full) => areElementsWithIssuesEqual(full, limited));
14
+ return fullMatch ? { ...limited, issues: [...limited.issues, ...fullMatch.issues] } : limited;
15
+ });
16
+ const onlyInFullContext = elementsFromFullContext.filter((full) => !elementsFromLimitedContext.some((limited) => areElementsWithIssuesEqual(limited, full)));
17
+ return [...fromLimitedWithFullIssuesMerged, ...onlyInFullContext];
18
+ }
19
+ export function updateElementsWithIssues({ extendedElementsWithIssues, limitedContext, limitedContextViolations, fullContextViolations, name, }) {
20
+ const updatedElementsFromLimitedContext = transformViolations(limitedContextViolations, name);
21
+ const updatedElementsFromFullContext = transformViolations(fullContextViolations, name);
22
+ const allUpdatedElements = mergeLimitedContextAndFullContextViolations(updatedElementsFromLimitedContext, updatedElementsFromFullContext);
27
23
  batch(() => {
28
- for (const updatedElementWithIssues of updatedElementsWithIssues) {
29
- const existingElementIndex = extendedElementsWithIssues.value.findIndex((extendedElementWithIssues) => areElementsWithIssuesEqual(extendedElementWithIssues, updatedElementWithIssues));
30
- if (existingElementIndex > -1 &&
31
- extendedElementsWithIssues.value[existingElementIndex] &&
32
- !areIssueSetsEqual(extendedElementsWithIssues.value[existingElementIndex].issues.value, updatedElementWithIssues.issues)) {
33
- extendedElementsWithIssues.value[existingElementIndex].issues.value =
34
- updatedElementWithIssues.issues;
24
+ for (const existing of extendedElementsWithIssues.value) {
25
+ // If the element is inside the limited context, axe just rescanned
26
+ // it replace its issues with whatever was reported. If it's outside, keep its
27
+ // existing issues, except descendant-dependent ones, which may have changed due
28
+ // to mutations elsewhere; those get repopulated from the full-context scan below.
29
+ const newLimitedContextIssues = isNodeInScanContext(existing.element, limitedContext)
30
+ ? getIssuesForElement(existing, updatedElementsFromLimitedContext)
31
+ : existing.issues.value.filter((issue) => !descendantDependentRules.has(issue.id));
32
+ const newFullContextIssues = getIssuesForElement(existing, updatedElementsFromFullContext);
33
+ const newIssues = [...newLimitedContextIssues, ...newFullContextIssues];
34
+ if (!areIssueSetsEqual(existing.issues.value, newIssues)) {
35
+ existing.issues.value = newIssues;
35
36
  }
36
37
  }
37
- const addedElementsWithIssues = updatedElementsWithIssues.filter((updatedElementWithIssues) => {
38
- return !extendedElementsWithIssues.value.some((extendedElementWithIssues) => areElementsWithIssuesEqual(extendedElementWithIssues, updatedElementWithIssues));
39
- });
40
- // Only consider an element to be removed in two cases:
41
- // 1. It has been removed from the DOM.
42
- // 2. It is within the scan context, but not among updatedElementsWithIssues.
43
- const removedElementsWithIssues = extendedElementsWithIssues.value.filter((extendedElementWithIssues) => {
44
- const isConnected = extendedElementWithIssues.element.isConnected;
45
- const hasNoMoreIssues = isNodeInScanContext(extendedElementWithIssues.element, scanContext) &&
46
- !updatedElementsWithIssues.some((updatedElementWithIssues) => areElementsWithIssuesEqual(updatedElementWithIssues, extendedElementWithIssues));
47
- return !isConnected || hasNoMoreIssues;
48
- });
38
+ const addedElementsWithIssues = allUpdatedElements.filter((updated) => updated.element.isConnected &&
39
+ !extendedElementsWithIssues.value.some((existing) => areElementsWithIssuesEqual(existing, updated)));
40
+ const removedElementsWithIssues = extendedElementsWithIssues.value.filter((existing) => !existing.element.isConnected || existing.issues.value.length === 0);
41
+ // Only rebuild the outer signal when set membership changes; per-element issue
42
+ // updates were already made in the loop above.
49
43
  if (addedElementsWithIssues.length > 0 || removedElementsWithIssues.length > 0) {
50
44
  extendedElementsWithIssues.value = [...extendedElementsWithIssues.value]
51
- .filter((extendedElementWithIssues) => {
52
- return !removedElementsWithIssues.some((removedElementWithIssues) => areElementsWithIssuesEqual(removedElementWithIssues, extendedElementWithIssues));
53
- })
54
- .concat(addedElementsWithIssues
55
- .filter((addedElementWithIssues) => addedElementWithIssues.element.isConnected)
56
- .map((addedElementWithIssues) => {
57
- const id = count++;
58
- const trigger = document.createElement(`${name}-trigger`);
59
- const elementZIndex = Number.parseInt(getComputedStyle(addedElementWithIssues.element).zIndex, 10);
60
- if (!Number.isNaN(elementZIndex)) {
61
- trigger.style.setProperty('z-index', (elementZIndex + 1).toString(), 'important');
62
- }
63
- trigger.style.setProperty('position-anchor', `--${name}-anchor-${id}`, 'important');
64
- trigger.dataset.id = id.toString();
65
- const accentedDialog = document.createElement(`${name}-dialog`);
66
- trigger.dialog = accentedDialog;
67
- const position = getElementPosition(addedElementWithIssues.element);
68
- trigger.position = signal(position);
69
- trigger.visible = signal(true);
70
- trigger.element = addedElementWithIssues.element;
71
- const scrollableAncestors = supportsAnchorPositioning()
72
- ? new Set()
73
- : getScrollableAncestors(addedElementWithIssues.element);
74
- const issues = signal(addedElementWithIssues.issues);
75
- accentedDialog.issues = issues;
76
- accentedDialog.element = addedElementWithIssues.element;
77
- return {
78
- id,
79
- element: addedElementWithIssues.element,
80
- skipRender: shouldSkipRender(addedElementWithIssues.element),
81
- rootNode: addedElementWithIssues.rootNode,
82
- visible: trigger.visible,
83
- position: trigger.position,
84
- scrollableAncestors: signal(scrollableAncestors),
85
- anchorNameValue: addedElementWithIssues.element.style.getPropertyValue('anchor-name') ||
86
- getComputedStyle(addedElementWithIssues.element).getPropertyValue('anchor-name'),
87
- trigger,
88
- issues,
89
- };
90
- }));
45
+ .filter((existing) => !removedElementsWithIssues.some((removed) => areElementsWithIssuesEqual(removed, existing)))
46
+ .concat(addedElementsWithIssues.map((added) => createExtendedElementWithIssues(added, name)));
91
47
  }
92
48
  });
93
49
  }
@@ -1 +1 @@
1
- {"version":3,"file":"update-elements-with-issues.js","sourceRoot":"","sources":["../../src/utils/update-elements-with-issues.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"update-elements-with-issues.js","sourceRoot":"","sources":["../../src/utils/update-elements-with-issues.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "accented",
3
- "version": "1.2.6",
3
+ "version": "1.3.1",
4
4
  "description": "A frontend library for continuous accessibility testing and issue highlighting",
5
5
  "type": "module",
6
6
  "main": "dist/accented.js",
@@ -27,12 +27,12 @@
27
27
  },
28
28
  "homepage": "https://accented.dev",
29
29
  "dependencies": {
30
- "@preact/signals-core": "^1.14.1",
31
- "axe-core": "^4.11.2"
30
+ "@preact/signals-core": "^1.14.2",
31
+ "axe-core": "^4.11.4"
32
32
  },
33
33
  "devDependencies": {
34
- "@types/jsdom": "^27.0.0",
35
- "jsdom": "^29.0.1"
34
+ "@types/jsdom": "28.0.3",
35
+ "jsdom": "29.1.1"
36
36
  },
37
37
  "scripts": {
38
38
  "build": "pnpm copyCommon && tsc",
package/src/constants.ts CHANGED
@@ -5,3 +5,37 @@ export const issuesUrl = 'https://github.com/pomerantsev/accented/issues';
5
5
  export const getAccentedElementNames = (name: string) => [`${name}-trigger`, `${name}-dialog`];
6
6
 
7
7
  export const orderedImpacts: Array<Issue['impact']> = ['minor', 'moderate', 'serious', 'critical'];
8
+
9
+ /**
10
+ * axe-core rules whose pass/fail depends on the presence or absence of specific descendants
11
+ * (not just direct children). When any DOM mutation occurs, these rules must be re-evaluated
12
+ * against the full scan context, because the mutated node may be deep inside the element
13
+ * that the violation is reported on — and therefore outside the limited scan context.
14
+ */
15
+ export const descendantDependentRules = new Set([
16
+ 'aria-hidden-focus',
17
+ 'aria-required-children',
18
+ 'aria-text',
19
+ 'document-title',
20
+ 'landmark-no-duplicate-banner',
21
+ 'landmark-no-duplicate-contentinfo',
22
+ 'landmark-no-duplicate-main',
23
+ 'landmark-one-main',
24
+ 'nested-interactive',
25
+ 'page-has-heading-one',
26
+ 'scrollable-region-focusable',
27
+ ]);
28
+
29
+ /**
30
+ * axe-core violations (their ids) that may be flagged by axe-core
31
+ * as false positives if an Accented trigger is a descendant of the element with the issue.
32
+ */
33
+ export const violationsAffectedByAccentedTriggers = new Set([
34
+ 'aria-hidden-focus',
35
+ 'aria-text',
36
+ 'definition-list',
37
+ 'label-content-name-mismatch',
38
+ 'list',
39
+ 'nested-interactive',
40
+ 'scrollable-region-focusable', // The Accented trigger might make the content grow such that scrolling is required.
41
+ ]);
@@ -242,7 +242,7 @@ export const getAccentedTrigger = (name: string): CustomElementConstructor => {
242
242
  }
243
243
 
244
244
  #setTransform() {
245
- if (cssTransformsAffectAnchorPositioning()) {
245
+ if (supportsAnchorPositioning() && cssTransformsAffectAnchorPositioning()) {
246
246
  return;
247
247
  }
248
248
  // We read and write values in separate animation frames to avoid layout thrashing.
package/src/scanner.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  import axe from 'axe-core';
2
- import { getAccentedElementNames, issuesUrl } from './constants.js';
2
+ import { descendantDependentRules, getAccentedElementNames, issuesUrl } from './constants.js';
3
3
  import { logAndRethrow } from './log-and-rethrow.js';
4
4
  import { elementsWithIssues, enabled, extendedElementsWithIssues } from './state.js';
5
5
  import { TaskQueue } from './task-queue.js';
6
6
  import type { AxeOptions, Callback, Context, Throttle } from './types.ts';
7
+ import { getAllRulesFromAxeOptions } from './utils/get-all-rules-from-axe-options.js';
7
8
  import { getScanContext } from './utils/get-scan-context.js';
8
9
  import { recalculatePositions } from './utils/recalculate-positions.js';
9
10
  import { recalculateScrollableAncestors } from './utils/recalculate-scrollable-ancestors.js';
@@ -18,10 +19,70 @@ export function createScanner(
18
19
  throttle: Required<Throttle>,
19
20
  callback: Callback,
20
21
  ) {
22
+ const allRules = getAllRulesFromAxeOptions(axeOptions);
23
+
24
+ /**
25
+ * Rules that only look at the element itself — safe to run
26
+ * against only the nodes affected by the current mutation.
27
+ */
28
+ const limitedContextRules = new Set(
29
+ [...allRules].filter((rule) => !descendantDependentRules.has(rule)),
30
+ );
31
+
32
+ /**
33
+ * Rules whose pass/fail depends on descendants anywhere in the subtree.
34
+ * A mutation deep inside an element can change the outcome for the ancestor,
35
+ * so these must always run against the full scan context.
36
+ */
37
+ const fullContextRules = new Set(
38
+ [...allRules].filter((rule) => descendantDependentRules.has(rule)),
39
+ );
40
+
41
+ /**
42
+ * Options shared by both axe.run() calls. The user's runOnly and rules are
43
+ * consumed by getAllRulesFromAxeOptions above and replaced by explicit rule
44
+ * sets, so they are not forwarded here.
45
+ */
46
+ const baseAxeOptions: axe.RunOptions = {
47
+ /**
48
+ * By default, axe-core doesn't include element refs
49
+ * in the violations array,
50
+ * and we need those element refs.
51
+ */
52
+ elementRef: true,
53
+
54
+ /**
55
+ * Although axe-core can perform iframe scanning, I haven't succeeded in it,
56
+ * and the docs suggest that the axe-core script should be explicitly included
57
+ * in each of the iframed documents anyway.
58
+ * It seems preferable to disallow iframe scanning and not report issues in elements within iframes
59
+ * in the case that such issues are for some reason reported by axe-core.
60
+ * A consumer of Accented can instead scan the iframed document by calling Accented initialization from that document.
61
+ */
62
+ iframes: false,
63
+
64
+ /**
65
+ * The `preload` docs are not clear to me,
66
+ * but when it's set to `true` by default,
67
+ * axe-core tries to fetch cross-origin CSS,
68
+ * which fails in the absence of CORS headers.
69
+ * I'm not sure why axe-core needs to preload
70
+ * those resources in the first place,
71
+ * so disabling it seems to be the safe option.
72
+ */
73
+ preload: false,
74
+
75
+ /**
76
+ * We're only interested in violations,
77
+ * not in passes or incomplete results.
78
+ */
79
+ resultTypes: ['violations'],
80
+ };
81
+
21
82
  const axeRunningWindowProp = `__${name}_axe_running__`;
22
83
  const win = window as unknown as Record<string, boolean>;
23
84
  const taskQueue = new TaskQueue<Node>(async (nodes) => {
24
- // We may see errors coming from axe-core when Accented is toggled off and on in qiuck succession,
85
+ // We may see errors coming from axe-core when Accented is toggled off and on in quick succession,
25
86
  // which I've seen happen with hot reloading of a React application.
26
87
  // This window property serves as a circuit breaker for that particular case.
27
88
  if (win[axeRunningWindowProp]) {
@@ -33,48 +94,32 @@ export function createScanner(
33
94
 
34
95
  win[axeRunningWindowProp] = true;
35
96
 
36
- const scanContext = getScanContext(nodes, context);
97
+ const limitedContext = getScanContext(nodes, context);
37
98
 
38
- let result: axe.AxeResults | undefined;
99
+ let limitedContextResult: axe.AxeResults | undefined;
100
+ let fullContextResult: axe.AxeResults | undefined;
39
101
 
40
102
  try {
41
- result = await axe.run(scanContext, {
42
- /**
43
- * By default, axe-core doesn't include element refs
44
- * in the violations array,
45
- * and we need those element refs.
46
- */
47
- elementRef: true,
48
-
49
- /**
50
- * Although axe-core can perform iframe scanning, I haven't succeeded in it,
51
- * and the docs suggest that the axe-core script should be explicitly included
52
- * in each of the iframed documents anyway.
53
- * It seems preferable to disallow iframe scanning and not report issues in elements within iframes
54
- * in the case that such issues are for some reason reported by axe-core.
55
- * A consumer of Accented can instead scan the iframed document by calling Accented initialization from that document.
56
- */
57
- iframes: false,
58
-
59
- /**
60
- * The `preload` docs are not clear to me,
61
- * but when it's set to `true` by default,
62
- * axe-core tries to fetch cross-origin CSS,
63
- * which fails in the absence of CORS headers.
64
- * I'm not sure why axe-core needs to preload
65
- * those resources in the first place,
66
- * so disabling it seems to be the safe option.
67
- */
68
- preload: false,
69
-
70
- /**
71
- * We're only interested in violations,
72
- * not in passes or incomplete results.
73
- */
74
- resultTypes: ['violations'],
75
-
76
- ...axeOptions,
77
- });
103
+ // Run the scan against the limited context (only the mutated
104
+ // nodes, filtered to those within the user-provided context). Skip if no
105
+ // limited-context rules are active.
106
+ limitedContextResult =
107
+ limitedContextRules.size > 0
108
+ ? await axe.run(limitedContext, {
109
+ ...baseAxeOptions,
110
+ runOnly: { type: 'rule', values: [...limitedContextRules] },
111
+ })
112
+ : undefined;
113
+
114
+ // Run the supplemental scan against the full context so that ancestor-
115
+ // dependent rules always see the complete DOM. Skip if none are active.
116
+ fullContextResult =
117
+ fullContextRules.size > 0
118
+ ? await axe.run(context, {
119
+ ...baseAxeOptions,
120
+ runOnly: { type: 'rule', values: [...fullContextRules] },
121
+ })
122
+ : undefined;
78
123
  } catch (error) {
79
124
  console.error(
80
125
  `Accented: axe-core (the accessibility testing engine) threw an error. Check the \`axeOptions\` property (https://accented.dev/api#axeoptions) that you’re passing to Accented. If you still think it’s a bug in Accented, file an issue at ${issuesUrl}.\n`,
@@ -86,7 +131,7 @@ export function createScanner(
86
131
  const scanMeasure = performance.measure('scan', 'scan-start');
87
132
  const scanDuration = Math.round(scanMeasure.duration);
88
133
 
89
- if (!enabled.value || !result) {
134
+ if (!enabled.value || (!limitedContextResult && !fullContextResult)) {
90
135
  return;
91
136
  }
92
137
 
@@ -94,8 +139,9 @@ export function createScanner(
94
139
 
95
140
  updateElementsWithIssues({
96
141
  extendedElementsWithIssues,
97
- scanContext,
98
- violations: result.violations,
142
+ limitedContext,
143
+ limitedContextViolations: limitedContextResult?.violations ?? [],
144
+ fullContextViolations: fullContextResult?.violations ?? [],
99
145
  name,
100
146
  });
101
147
 
@@ -106,7 +152,7 @@ export function createScanner(
106
152
  // Assuming that the {include, exclude} shape of the context object will be used less often
107
153
  // than other variants, we'll output just the `include` array in case nothing is excluded
108
154
  // in the scan.
109
- scanContext: scanContext.exclude.length > 0 ? scanContext : scanContext.include,
155
+ scanContext: limitedContext.exclude.length > 0 ? limitedContext : limitedContext.include,
110
156
  elementsWithIssues: elementsWithIssues.value,
111
157
  performance: {
112
158
  totalBlockingTime: scanDuration + domUpdateDuration,
@@ -0,0 +1,67 @@
1
+ import { signal } from '@preact/signals-core';
2
+ import type { AccentedDialog } from '../elements/accented-dialog.ts';
3
+ import type { AccentedTrigger } from '../elements/accented-trigger.ts';
4
+ import type { ElementWithIssues, ExtendedElementWithIssues } from '../types.ts';
5
+ import { isSvgElement } from './dom-helpers.js';
6
+ import { getElementPosition } from './get-element-position.js';
7
+ import { getParent } from './get-parent.js';
8
+ import { getScrollableAncestors } from './get-scrollable-ancestors.js';
9
+ import { supportsAnchorPositioning } from './supports-anchor-positioning.js';
10
+
11
+ function shouldSkipRender(element: Element): boolean {
12
+ // Skip rendering if the element is inside an SVG:
13
+ // https://github.com/pomerantsev/accented/issues/62
14
+ const parent = getParent(element);
15
+ const isInsideSvg = Boolean(parent && isSvgElement(parent));
16
+
17
+ // Some issues, such as meta-viewport, are on <head> descendants,
18
+ // but since <head> is never rendered, we don't want to output anything
19
+ // for those in the DOM.
20
+ // We're not anticipating the use of shadow DOM in <head>,
21
+ // so the use of .closest() should be fine.
22
+ const isInsideHead = element.closest('head') !== null;
23
+
24
+ return isInsideSvg || isInsideHead;
25
+ }
26
+
27
+ let count = 0;
28
+
29
+ export function createExtendedElementWithIssues(
30
+ elementWithIssues: ElementWithIssues,
31
+ name: string,
32
+ ): ExtendedElementWithIssues {
33
+ const id = count++;
34
+ const trigger = document.createElement(`${name}-trigger`) as AccentedTrigger;
35
+ const elementZIndex = Number.parseInt(getComputedStyle(elementWithIssues.element).zIndex, 10);
36
+ if (!Number.isNaN(elementZIndex)) {
37
+ trigger.style.setProperty('z-index', (elementZIndex + 1).toString(), 'important');
38
+ }
39
+ trigger.style.setProperty('position-anchor', `--${name}-anchor-${id}`, 'important');
40
+ trigger.dataset.id = id.toString();
41
+ const accentedDialog = document.createElement(`${name}-dialog`) as AccentedDialog;
42
+ trigger.dialog = accentedDialog;
43
+ const position = getElementPosition(elementWithIssues.element);
44
+ trigger.position = signal(position);
45
+ trigger.visible = signal(true);
46
+ trigger.element = elementWithIssues.element;
47
+ const scrollableAncestors = supportsAnchorPositioning()
48
+ ? new Set<HTMLElement>()
49
+ : getScrollableAncestors(elementWithIssues.element);
50
+ const issues = signal(elementWithIssues.issues);
51
+ accentedDialog.issues = issues;
52
+ accentedDialog.element = elementWithIssues.element;
53
+ return {
54
+ id,
55
+ element: elementWithIssues.element,
56
+ skipRender: shouldSkipRender(elementWithIssues.element),
57
+ rootNode: elementWithIssues.rootNode,
58
+ visible: trigger.visible,
59
+ position: trigger.position,
60
+ scrollableAncestors: signal(scrollableAncestors),
61
+ anchorNameValue:
62
+ elementWithIssues.element.style.getPropertyValue('anchor-name') ||
63
+ getComputedStyle(elementWithIssues.element).getPropertyValue('anchor-name'),
64
+ trigger,
65
+ issues,
66
+ };
67
+ }