@zerolith/test 0.1.0-alpha.5.canary.20261031.g3c0e1fb → 0.1.0-alpha.6

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.
package/README.md CHANGED
@@ -19,12 +19,12 @@ it("renders and hydrates", async () =>
19
19
  });
20
20
  ```
21
21
 
22
- - `mount` renders a view into a container attached to the document and remembers it; `cleanup` disposes everything mounted, in reverse order.
22
+ - `mount` renders a view into a container attached to the document and remembers it; given a function, as in `mount(() => Dashboard())`, it calls it inside an owner of its own, so a component that registers cleanups with `onDispose` mounts as it is. `cleanup` disposes everything mounted, in reverse order.
23
23
  - `settle` waits for the promises a test started, such as resources and lazy routes; effects themselves run synchronously.
24
24
  - `hydrationMismatches` renders a view with the server renderer, hydrates the same view over that markup, and returns every disagreement, so a component that cannot hydrate fails its test.
25
25
  - `accessibilityIssues` finds the defects a test can catch without a browser engine's accessibility tree: images without alternative text, controls without a label, buttons and links without a name, frames without a title.
26
26
 
27
- The guide is on the [docs site](https://molexxxx.github.io/zerolith/guide/testing/).
27
+ Under Vitest, which reads the components itself, add the plugin from `@zerolith/vite` as `zerolith({ hot: false })` so a test reads JSX and stylesheets imported with `?scoped` as the app does; a project from `zerolith new` has it in `vitest.config.ts`. The guide is on the [docs site](https://molexxxx.github.io/zerolith/guide/testing/).
28
28
 
29
29
  Status: alpha. Install with `npm install --save-dev @zerolith/test@alpha`; the API may change before 1.0.
30
30
 
package/dist/index.d.ts CHANGED
@@ -9,6 +9,7 @@ import { type HydrationMismatch, type Root } from "zerolith";
9
9
  export interface Mounted {
10
10
  /** The element the view was rendered into. */
11
11
  readonly container: HTMLElement;
12
+ /** The root `render` returned for the view; `unmount` disposes it. */
12
13
  readonly root: Root;
13
14
  /** Disposes the view and removes the container; `cleanup` does the same for every mount. */
14
15
  unmount(): void;
@@ -17,30 +18,45 @@ export interface Mounted {
17
18
  export interface MountOptions {
18
19
  /** Renders into this element instead of a new `div`. */
19
20
  container?: HTMLElement;
20
- /** Appends a new container to `document.body`. Defaults to true; layout and focus need it. */
21
+ /** Appends a new container to `document.body`. Defaults to true; layout and focus need it. Ignored with `container`. */
21
22
  attach?: boolean;
22
23
  }
23
- /** Renders a view into a container and remembers it for `cleanup`. */
24
+ /**
25
+ * Renders a view into a container and remembers it for `cleanup`. Given a
26
+ * function that returns the view, as `mount(() => Page())`, it hands the
27
+ * function to `render`, which calls it inside the root's owner, so a
28
+ * component that registers cleanups with `onDispose` mounts as it is, and
29
+ * its cleanups run when the view is unmounted. A component that takes
30
+ * props, passed uncalled, fails with `mount_component_uncalled`, as
31
+ * `render` does. A container `mount` created is removed even when
32
+ * rendering throws.
33
+ */
24
34
  export declare function mount(view: unknown, options?: MountOptions): Mounted;
25
- /** Unmounts everything `mount` created, newest first. Register it as the runner's after-each hook. */
35
+ /** Unmounts everything `mount` created, newest first, each even when another's cleanup throws, and then throws the first error. Register it as the runner's after-each hook. */
26
36
  export declare function cleanup(): void;
27
37
  /**
28
- * Waits for the asynchronous work a test started: resources, lazy routes,
29
- * and anything scheduled with promises or a timer of zero. Effects run
30
- * synchronously when a signal changes, so they need no settling.
38
+ * Waits `rounds` turns of the timer queue, two by default: enough for
39
+ * promise chains, a resource whose loader resolves at once, and zero-delay
40
+ * timers. Effects run synchronously when a signal changes, so they need no
41
+ * settling. For a module import or a request, wait on its result with the
42
+ * runner's `waitFor` instead.
31
43
  */
32
44
  export declare function settle(rounds?: number): Promise<void>;
33
45
  /**
34
- * Renders `view()` with the server renderer, hydrates a second `view()` over
35
- * that markup, and returns every disagreement. An empty array means the
36
- * component hydrates cleanly; the container is discarded afterwards.
46
+ * Renders `view` with the server renderer, hydrates it over that markup,
47
+ * and returns every disagreement. Each pass calls `view` inside its own
48
+ * owner, so the effects and `onDispose` cleanups a component registers end
49
+ * before the helper returns. An empty array means the component hydrates
50
+ * cleanly; the container is discarded afterwards.
37
51
  */
38
52
  export declare function hydrationMismatches(view: () => unknown): HydrationMismatch[];
39
53
  /** The accessibility checks `accessibilityIssues` runs. */
40
54
  export type AccessibilityCode = "image_without_alt" | "control_without_label" | "button_without_name" | "link_without_name" | "frame_without_title";
41
55
  /** One defect found by `accessibilityIssues`. */
42
56
  export interface AccessibilityIssue {
57
+ /** Which check the element failed, such as `image_without_alt`. */
43
58
  readonly code: AccessibilityCode;
59
+ /** The element that failed the check, found inside the container passed to `accessibilityIssues`. */
44
60
  readonly element: Element;
45
61
  }
46
62
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EAAE,KAAK,iBAAiB,EAAE,KAAK,IAAI,EAAmB,MAAM,UAAU,CAAC;AAG9E,4BAA4B;AAC5B,MAAM,WAAW,OAAO;IAEpB,8CAA8C;IAC9C,QAAQ,CAAC,SAAS,EAAE,WAAW,CAAC;IAChC,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAC;IACpB,4FAA4F;IAC5F,OAAO,IAAI,IAAI,CAAC;CACnB;AAED,2BAA2B;AAC3B,MAAM,WAAW,YAAY;IAEzB,wDAAwD;IACxD,SAAS,CAAC,EAAE,WAAW,CAAC;IACxB,8FAA8F;IAC9F,MAAM,CAAC,EAAE,OAAO,CAAC;CACpB;AAID,sEAAsE;AACtE,wBAAgB,KAAK,CAAC,IAAI,EAAE,OAAO,EAAE,OAAO,GAAE,YAAiB,GAAG,OAAO,CA2BxE;AAED,sGAAsG;AACtG,wBAAgB,OAAO,IAAI,IAAI,CAM9B;AAED;;;;GAIG;AACH,wBAAgB,MAAM,CAAC,MAAM,SAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAQhD;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,OAAO,GAAG,iBAAiB,EAAE,CAQ5E;AAED,2DAA2D;AAC3D,MAAM,MAAM,iBAAiB,GAAG,mBAAmB,GAAG,uBAAuB,GAAG,qBAAqB,GAAG,mBAAmB,GAAG,qBAAqB,CAAC;AAEpJ,iDAAiD;AACjD,MAAM,WAAW,kBAAkB;IAE/B,QAAQ,CAAC,IAAI,EAAE,iBAAiB,CAAC;IACjC,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;CAC7B;AA0BD;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,SAAS,EAAE,UAAU,GAAG,kBAAkB,EAAE,CAqC/E"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EAAE,KAAK,iBAAiB,EAAE,KAAK,IAAI,EAAmB,MAAM,UAAU,CAAC;AAG9E,4BAA4B;AAC5B,MAAM,WAAW,OAAO;IAEpB,8CAA8C;IAC9C,QAAQ,CAAC,SAAS,EAAE,WAAW,CAAC;IAChC,sEAAsE;IACtE,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAC;IACpB,4FAA4F;IAC5F,OAAO,IAAI,IAAI,CAAC;CACnB;AAED,2BAA2B;AAC3B,MAAM,WAAW,YAAY;IAEzB,wDAAwD;IACxD,SAAS,CAAC,EAAE,WAAW,CAAC;IACxB,wHAAwH;IACxH,MAAM,CAAC,EAAE,OAAO,CAAC;CACpB;AAID;;;;;;;;;GASG;AACH,wBAAgB,KAAK,CAAC,IAAI,EAAE,OAAO,EAAE,OAAO,GAAE,YAAiB,GAAG,OAAO,CA6CxE;AAED,gLAAgL;AAChL,wBAAgB,OAAO,IAAI,IAAI,CAkB9B;AAED;;;;;;GAMG;AACH,wBAAgB,MAAM,CAAC,MAAM,SAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAQhD;AAED;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,OAAO,GAAG,iBAAiB,EAAE,CAQ5E;AAED,2DAA2D;AAC3D,MAAM,MAAM,iBAAiB,GAAG,mBAAmB,GAAG,uBAAuB,GAAG,qBAAqB,GAAG,mBAAmB,GAAG,qBAAqB,CAAC;AAEpJ,iDAAiD;AACjD,MAAM,WAAW,kBAAkB;IAE/B,mEAAmE;IACnE,QAAQ,CAAC,IAAI,EAAE,iBAAiB,CAAC;IACjC,qGAAqG;IACrG,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;CAC7B;AA8CD;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,SAAS,EAAE,UAAU,GAAG,kBAAkB,EAAE,CAqC/E"}
package/dist/index.js CHANGED
@@ -7,13 +7,31 @@
7
7
  import { hydrate, render } from "zerolith";
8
8
  import { renderToString } from "zerolith/ssr";
9
9
  const mounted = [];
10
- /** Renders a view into a container and remembers it for `cleanup`. */
10
+ /**
11
+ * Renders a view into a container and remembers it for `cleanup`. Given a
12
+ * function that returns the view, as `mount(() => Page())`, it hands the
13
+ * function to `render`, which calls it inside the root's owner, so a
14
+ * component that registers cleanups with `onDispose` mounts as it is, and
15
+ * its cleanups run when the view is unmounted. A component that takes
16
+ * props, passed uncalled, fails with `mount_component_uncalled`, as
17
+ * `render` does. A container `mount` created is removed even when
18
+ * rendering throws.
19
+ */
11
20
  export function mount(view, options = {}) {
12
21
  const container = options.container ?? document.createElement("div");
13
22
  if (options.container === undefined && options.attach !== false) {
14
23
  document.body.append(container);
15
24
  }
16
- const root = render(view, container);
25
+ let root;
26
+ try {
27
+ root = render(view, container);
28
+ }
29
+ catch (error) {
30
+ if (options.container === undefined) {
31
+ container.remove();
32
+ }
33
+ throw error;
34
+ }
17
35
  const entry = {
18
36
  container,
19
37
  root,
@@ -22,25 +40,40 @@ export function mount(view, options = {}) {
22
40
  if (index !== -1) {
23
41
  mounted.splice(index, 1);
24
42
  }
25
- root.dispose();
26
- if (options.container === undefined) {
27
- container.remove();
43
+ try {
44
+ root.dispose();
45
+ }
46
+ finally {
47
+ if (options.container === undefined) {
48
+ container.remove();
49
+ }
28
50
  }
29
51
  }
30
52
  };
31
53
  mounted.push(entry);
32
54
  return entry;
33
55
  }
34
- /** Unmounts everything `mount` created, newest first. Register it as the runner's after-each hook. */
56
+ /** Unmounts everything `mount` created, newest first, each even when another's cleanup throws, and then throws the first error. Register it as the runner's after-each hook. */
35
57
  export function cleanup() {
58
+ const errors = [];
36
59
  for (const entry of mounted.toReversed()) {
37
- entry.unmount();
60
+ try {
61
+ entry.unmount();
62
+ }
63
+ catch (error) {
64
+ errors.push(error);
65
+ }
66
+ }
67
+ if (errors.length > 0) {
68
+ throw errors[0];
38
69
  }
39
70
  }
40
71
  /**
41
- * Waits for the asynchronous work a test started: resources, lazy routes,
42
- * and anything scheduled with promises or a timer of zero. Effects run
43
- * synchronously when a signal changes, so they need no settling.
72
+ * Waits `rounds` turns of the timer queue, two by default: enough for
73
+ * promise chains, a resource whose loader resolves at once, and zero-delay
74
+ * timers. Effects run synchronously when a signal changes, so they need no
75
+ * settling. For a module import or a request, wait on its result with the
76
+ * runner's `waitFor` instead.
44
77
  */
45
78
  export function settle(rounds = 2) {
46
79
  let chain = Promise.resolve();
@@ -50,34 +83,50 @@ export function settle(rounds = 2) {
50
83
  return chain;
51
84
  }
52
85
  /**
53
- * Renders `view()` with the server renderer, hydrates a second `view()` over
54
- * that markup, and returns every disagreement. An empty array means the
55
- * component hydrates cleanly; the container is discarded afterwards.
86
+ * Renders `view` with the server renderer, hydrates it over that markup,
87
+ * and returns every disagreement. Each pass calls `view` inside its own
88
+ * owner, so the effects and `onDispose` cleanups a component registers end
89
+ * before the helper returns. An empty array means the component hydrates
90
+ * cleanly; the container is discarded afterwards.
56
91
  */
57
92
  export function hydrationMismatches(view) {
58
93
  const container = document.createElement("div");
59
- container.innerHTML = renderToString(view());
60
- const result = hydrate(view(), container);
94
+ container.innerHTML = renderToString(view);
95
+ const result = hydrate(view, container);
61
96
  const mismatches = [...result.mismatches];
62
97
  result.dispose();
63
98
  return mismatches;
64
99
  }
65
- function hasName(element) {
66
- const label = element.getAttribute("aria-label") ?? "";
67
- if (label.trim() !== "" || element.hasAttribute("aria-labelledby") || (element.getAttribute("title") ?? "").trim() !== "") {
68
- return true;
100
+ /** True when an element has an accessible name from an attribute: `aria-label`, `aria-labelledby`, or `title`. */
101
+ function namedByAttribute(element) {
102
+ return (element.getAttribute("aria-label") ?? "").trim() !== "" || element.hasAttribute("aria-labelledby") || (element.getAttribute("title") ?? "").trim() !== "";
103
+ }
104
+ /** The text an element shows to assistive technology: its text content, leaving out every `aria-hidden="true"` subtree. */
105
+ function exposedText(element) {
106
+ let text = "";
107
+ for (const node of element.childNodes) {
108
+ if (node.nodeType === Node.TEXT_NODE) {
109
+ text += node.textContent ?? "";
110
+ }
111
+ else if (node instanceof Element && node.getAttribute("aria-hidden") !== "true") {
112
+ text += exposedText(node);
113
+ }
69
114
  }
70
- if ((element.textContent ?? "").trim() !== "") {
115
+ return text;
116
+ }
117
+ function hasName(element) {
118
+ if (namedByAttribute(element) || exposedText(element).trim() !== "") {
71
119
  return true;
72
120
  }
73
- return [...element.querySelectorAll("img[alt]")].some((image) => (image.getAttribute("alt") ?? "").trim() !== "");
121
+ return [...element.querySelectorAll("img[alt]")].some((image) => (image.getAttribute("alt") ?? "").trim() !== "" && image.closest("[aria-hidden=\"true\"]") === null);
74
122
  }
75
123
  function isLabeled(control) {
76
124
  if (hasName(control) || control.closest("label") !== null) {
77
125
  return true;
78
126
  }
79
127
  const id = control.getAttribute("id");
80
- return id !== null && id !== "" && control.ownerDocument.querySelector(`label[for="${CSS.escape(id)}"]`) !== null;
128
+ const tree = control.getRootNode();
129
+ return id !== null && id !== "" && [...tree.querySelectorAll("label[for]")].some((label) => label.htmlFor === id);
81
130
  }
82
131
  /**
83
132
  * Finds the defects that need no accessibility tree to detect, the ones the
@@ -107,7 +156,7 @@ export function accessibilityIssues(container) {
107
156
  }
108
157
  }
109
158
  for (const frame of container.querySelectorAll("iframe")) {
110
- if ((frame.getAttribute("title") ?? "").trim() === "") {
159
+ if (!namedByAttribute(frame)) {
111
160
  issues.push({ code: "frame_without_title", element: frame });
112
161
  }
113
162
  }
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerolith/test",
3
- "version": "0.1.0-alpha.5.canary.20261031.g3c0e1fb",
3
+ "version": "0.1.0-alpha.6",
4
4
  "description": "Test helpers for zerolith: mount views with automatic cleanup, settle async work, check a view's server markup against hydration, and find the accessibility defects a test can catch",
5
5
  "license": "Apache-2.0",
6
6
  "author": "molexxxx",
@@ -41,9 +41,9 @@
41
41
  "build": "tsc -p tsconfig.build.json"
42
42
  },
43
43
  "peerDependencies": {
44
- "zerolith": "^0.1.0-alpha.5.canary.20261031.g3c0e1fb"
44
+ "zerolith": "^0.1.0-alpha.6"
45
45
  },
46
46
  "devDependencies": {
47
- "zerolith": "^0.1.0-alpha.5.canary.20261031.g3c0e1fb"
47
+ "zerolith": "^0.1.0-alpha.6"
48
48
  }
49
49
  }
package/src/index.ts CHANGED
@@ -12,6 +12,7 @@ export interface Mounted
12
12
  {
13
13
  /** The element the view was rendered into. */
14
14
  readonly container: HTMLElement;
15
+ /** The root `render` returned for the view; `unmount` disposes it. */
15
16
  readonly root: Root;
16
17
  /** Disposes the view and removes the container; `cleanup` does the same for every mount. */
17
18
  unmount(): void;
@@ -22,13 +23,22 @@ export interface MountOptions
22
23
  {
23
24
  /** Renders into this element instead of a new `div`. */
24
25
  container?: HTMLElement;
25
- /** Appends a new container to `document.body`. Defaults to true; layout and focus need it. */
26
+ /** Appends a new container to `document.body`. Defaults to true; layout and focus need it. Ignored with `container`. */
26
27
  attach?: boolean;
27
28
  }
28
29
 
29
30
  const mounted: Mounted[] = [];
30
31
 
31
- /** Renders a view into a container and remembers it for `cleanup`. */
32
+ /**
33
+ * Renders a view into a container and remembers it for `cleanup`. Given a
34
+ * function that returns the view, as `mount(() => Page())`, it hands the
35
+ * function to `render`, which calls it inside the root's owner, so a
36
+ * component that registers cleanups with `onDispose` mounts as it is, and
37
+ * its cleanups run when the view is unmounted. A component that takes
38
+ * props, passed uncalled, fails with `mount_component_uncalled`, as
39
+ * `render` does. A container `mount` created is removed even when
40
+ * rendering throws.
41
+ */
32
42
  export function mount(view: unknown, options: MountOptions = {}): Mounted
33
43
  {
34
44
  const container = options.container ?? document.createElement("div");
@@ -36,7 +46,19 @@ export function mount(view: unknown, options: MountOptions = {}): Mounted
36
46
  {
37
47
  document.body.append(container);
38
48
  }
39
- const root = render(view, container);
49
+ let root: Root;
50
+ try
51
+ {
52
+ root = render(view, container);
53
+ }
54
+ catch (error)
55
+ {
56
+ if (options.container === undefined)
57
+ {
58
+ container.remove();
59
+ }
60
+ throw error;
61
+ }
40
62
  const entry: Mounted = {
41
63
  container,
42
64
  root,
@@ -47,10 +69,16 @@ export function mount(view: unknown, options: MountOptions = {}): Mounted
47
69
  {
48
70
  mounted.splice(index, 1);
49
71
  }
50
- root.dispose();
51
- if (options.container === undefined)
72
+ try
52
73
  {
53
- container.remove();
74
+ root.dispose();
75
+ }
76
+ finally
77
+ {
78
+ if (options.container === undefined)
79
+ {
80
+ container.remove();
81
+ }
54
82
  }
55
83
  }
56
84
  };
@@ -58,19 +86,33 @@ export function mount(view: unknown, options: MountOptions = {}): Mounted
58
86
  return entry;
59
87
  }
60
88
 
61
- /** Unmounts everything `mount` created, newest first. Register it as the runner's after-each hook. */
89
+ /** Unmounts everything `mount` created, newest first, each even when another's cleanup throws, and then throws the first error. Register it as the runner's after-each hook. */
62
90
  export function cleanup(): void
63
91
  {
92
+ const errors: unknown[] = [];
64
93
  for (const entry of mounted.toReversed())
65
94
  {
66
- entry.unmount();
95
+ try
96
+ {
97
+ entry.unmount();
98
+ }
99
+ catch (error)
100
+ {
101
+ errors.push(error);
102
+ }
103
+ }
104
+ if (errors.length > 0)
105
+ {
106
+ throw errors[0];
67
107
  }
68
108
  }
69
109
 
70
110
  /**
71
- * Waits for the asynchronous work a test started: resources, lazy routes,
72
- * and anything scheduled with promises or a timer of zero. Effects run
73
- * synchronously when a signal changes, so they need no settling.
111
+ * Waits `rounds` turns of the timer queue, two by default: enough for
112
+ * promise chains, a resource whose loader resolves at once, and zero-delay
113
+ * timers. Effects run synchronously when a signal changes, so they need no
114
+ * settling. For a module import or a request, wait on its result with the
115
+ * runner's `waitFor` instead.
74
116
  */
75
117
  export function settle(rounds = 2): Promise<void>
76
118
  {
@@ -83,15 +125,17 @@ export function settle(rounds = 2): Promise<void>
83
125
  }
84
126
 
85
127
  /**
86
- * Renders `view()` with the server renderer, hydrates a second `view()` over
87
- * that markup, and returns every disagreement. An empty array means the
88
- * component hydrates cleanly; the container is discarded afterwards.
128
+ * Renders `view` with the server renderer, hydrates it over that markup,
129
+ * and returns every disagreement. Each pass calls `view` inside its own
130
+ * owner, so the effects and `onDispose` cleanups a component registers end
131
+ * before the helper returns. An empty array means the component hydrates
132
+ * cleanly; the container is discarded afterwards.
89
133
  */
90
134
  export function hydrationMismatches(view: () => unknown): HydrationMismatch[]
91
135
  {
92
136
  const container = document.createElement("div");
93
- container.innerHTML = renderToString(view());
94
- const result = hydrate(view(), container);
137
+ container.innerHTML = renderToString(view);
138
+ const result = hydrate(view, container);
95
139
  const mismatches = [...result.mismatches];
96
140
  result.dispose();
97
141
  return mismatches;
@@ -103,22 +147,43 @@ export type AccessibilityCode = "image_without_alt" | "control_without_label" |
103
147
  /** One defect found by `accessibilityIssues`. */
104
148
  export interface AccessibilityIssue
105
149
  {
150
+ /** Which check the element failed, such as `image_without_alt`. */
106
151
  readonly code: AccessibilityCode;
152
+ /** The element that failed the check, found inside the container passed to `accessibilityIssues`. */
107
153
  readonly element: Element;
108
154
  }
109
155
 
110
- function hasName(element: Element): boolean
156
+ /** True when an element has an accessible name from an attribute: `aria-label`, `aria-labelledby`, or `title`. */
157
+ function namedByAttribute(element: Element): boolean
111
158
  {
112
- const label = element.getAttribute("aria-label") ?? "";
113
- if (label.trim() !== "" || element.hasAttribute("aria-labelledby") || (element.getAttribute("title") ?? "").trim() !== "")
159
+ return (element.getAttribute("aria-label") ?? "").trim() !== "" || element.hasAttribute("aria-labelledby") || (element.getAttribute("title") ?? "").trim() !== "";
160
+ }
161
+
162
+ /** The text an element shows to assistive technology: its text content, leaving out every `aria-hidden="true"` subtree. */
163
+ function exposedText(element: Element): string
164
+ {
165
+ let text = "";
166
+ for (const node of element.childNodes)
114
167
  {
115
- return true;
168
+ if (node.nodeType === Node.TEXT_NODE)
169
+ {
170
+ text += node.textContent ?? "";
171
+ }
172
+ else if (node instanceof Element && node.getAttribute("aria-hidden") !== "true")
173
+ {
174
+ text += exposedText(node);
175
+ }
116
176
  }
117
- if ((element.textContent ?? "").trim() !== "")
177
+ return text;
178
+ }
179
+
180
+ function hasName(element: Element): boolean
181
+ {
182
+ if (namedByAttribute(element) || exposedText(element).trim() !== "")
118
183
  {
119
184
  return true;
120
185
  }
121
- return [...element.querySelectorAll("img[alt]")].some((image) => (image.getAttribute("alt") ?? "").trim() !== "");
186
+ return [...element.querySelectorAll("img[alt]")].some((image) => (image.getAttribute("alt") ?? "").trim() !== "" && image.closest("[aria-hidden=\"true\"]") === null);
122
187
  }
123
188
 
124
189
  function isLabeled(control: Element): boolean
@@ -128,7 +193,8 @@ function isLabeled(control: Element): boolean
128
193
  return true;
129
194
  }
130
195
  const id = control.getAttribute("id");
131
- return id !== null && id !== "" && control.ownerDocument.querySelector(`label[for="${CSS.escape(id)}"]`) !== null;
196
+ const tree = control.getRootNode() as ParentNode;
197
+ return id !== null && id !== "" && [...tree.querySelectorAll("label[for]")].some((label) => (label as HTMLLabelElement).htmlFor === id);
132
198
  }
133
199
 
134
200
  /**
@@ -168,7 +234,7 @@ export function accessibilityIssues(container: ParentNode): AccessibilityIssue[]
168
234
  }
169
235
  for (const frame of container.querySelectorAll("iframe"))
170
236
  {
171
- if ((frame.getAttribute("title") ?? "").trim() === "")
237
+ if (!namedByAttribute(frame))
172
238
  {
173
239
  issues.push({ code: "frame_without_title", element: frame });
174
240
  }