html-validate 7.5.0 → 7.6.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.
package/dist/es/core.js CHANGED
@@ -1736,9 +1736,19 @@ function factory(name, context) {
1736
1736
  function stripslashes(value) {
1737
1737
  return value.replace(/\\(.)/g, "$1");
1738
1738
  }
1739
+ /**
1740
+ * @internal
1741
+ */
1739
1742
  function escapeSelectorComponent(text) {
1740
1743
  return text.toString().replace(/([^a-z0-9_-])/gi, "\\$1");
1741
1744
  }
1745
+ /**
1746
+ * @internal
1747
+ */
1748
+ function generateIdSelector(id) {
1749
+ const escaped = escapeSelectorComponent(id);
1750
+ return escaped.match(/^\d/) ? `[id="${escaped}"]` : `#${escaped}`;
1751
+ }
1742
1752
  /**
1743
1753
  * Returns true if the character is a delimiter for different kinds of selectors:
1744
1754
  *
@@ -2115,6 +2125,25 @@ class HtmlElement extends DOMNode {
2115
2125
  return `<${this.tagName}>`;
2116
2126
  }
2117
2127
  }
2128
+ /**
2129
+ * Get list of IDs referenced by `aria-labelledby`.
2130
+ *
2131
+ * If the attribute is unset or empty this getter returns null.
2132
+ * If the attribute is dynamic the original {@link DynamicValue} is returned.
2133
+ *
2134
+ * @public
2135
+ */
2136
+ get ariaLabelledby() {
2137
+ const attr = this.getAttribute("aria-labelledby");
2138
+ if (!attr || !attr.value) {
2139
+ return null;
2140
+ }
2141
+ if (attr.value instanceof DynamicValue) {
2142
+ return attr.value;
2143
+ }
2144
+ const list = new DOMTokenList(attr.value, attr.valueLocation);
2145
+ return list.length ? Array.from(list) : null;
2146
+ }
2118
2147
  /**
2119
2148
  * Similar to childNodes but only elements.
2120
2149
  */
@@ -2156,8 +2185,7 @@ class HtmlElement extends DOMNode {
2156
2185
  for (let cur = this; cur.parent; cur = cur.parent) {
2157
2186
  /* if a unique id is present, use it and short-circuit */
2158
2187
  if (cur.id) {
2159
- const escaped = escapeSelectorComponent(cur.id);
2160
- const selector = escaped.match(/^\d/) ? `[id="${escaped}"]` : `#${escaped}`;
2188
+ const selector = generateIdSelector(cur.id);
2161
2189
  const matches = root.querySelectorAll(selector);
2162
2190
  if (matches.length === 1) {
2163
2191
  parts.push(selector);
@@ -3163,7 +3191,7 @@ var TRANSFORMER_API;
3163
3191
  /** @public */
3164
3192
  const name = "html-validate";
3165
3193
  /** @public */
3166
- const version = "7.5.0";
3194
+ const version = "7.6.0";
3167
3195
  /** @public */
3168
3196
  const homepage = "https://html-validate.org";
3169
3197
  /** @public */
@@ -5386,13 +5414,122 @@ class ElementPermittedOrder extends Rule {
5386
5414
  }
5387
5415
  }
5388
5416
 
5389
- const CACHE_KEY = Symbol(classifyNodeText.name);
5417
+ const ARIA_HIDDEN_CACHE = Symbol(isAriaHidden.name);
5418
+ const HTML_HIDDEN_CACHE = Symbol(isHTMLHidden.name);
5419
+ const ROLE_PRESENTATION_CACHE = Symbol(isPresentation.name);
5420
+ /**
5421
+ * Tests if this element is present in the accessibility tree.
5422
+ *
5423
+ * In practice it tests whenever the element or its parents has
5424
+ * `role="presentation"` or `aria-hidden="false"`. Dynamic values counts as
5425
+ * visible since the element might be in the visibility tree sometimes.
5426
+ */
5427
+ function inAccessibilityTree(node) {
5428
+ return !isAriaHidden(node) && !isPresentation(node);
5429
+ }
5430
+ function isAriaHiddenImpl(node) {
5431
+ const isHidden = (node) => {
5432
+ const ariaHidden = node.getAttribute("aria-hidden");
5433
+ return Boolean(ariaHidden && ariaHidden.value === "true");
5434
+ };
5435
+ return {
5436
+ byParent: node.parent ? isAriaHidden(node.parent) : false,
5437
+ bySelf: isHidden(node),
5438
+ };
5439
+ }
5440
+ function isAriaHidden(node, details) {
5441
+ const cached = node.cacheGet(ARIA_HIDDEN_CACHE);
5442
+ if (cached) {
5443
+ return details ? cached : cached.byParent || cached.bySelf;
5444
+ }
5445
+ const result = node.cacheSet(ARIA_HIDDEN_CACHE, isAriaHiddenImpl(node));
5446
+ return details ? result : result.byParent || result.bySelf;
5447
+ }
5448
+ function isHTMLHiddenImpl(node) {
5449
+ const isHidden = (node) => {
5450
+ const hidden = node.getAttribute("hidden");
5451
+ return hidden !== null && hidden.isStatic;
5452
+ };
5453
+ return {
5454
+ byParent: node.parent ? isHTMLHidden(node.parent) : false,
5455
+ bySelf: isHidden(node),
5456
+ };
5457
+ }
5458
+ function isHTMLHidden(node, details) {
5459
+ const cached = node.cacheGet(HTML_HIDDEN_CACHE);
5460
+ if (cached) {
5461
+ return details ? cached : cached.byParent || cached.bySelf;
5462
+ }
5463
+ const result = node.cacheSet(HTML_HIDDEN_CACHE, isHTMLHiddenImpl(node));
5464
+ return details ? result : result.byParent || result.bySelf;
5465
+ }
5466
+ /**
5467
+ * Tests if this element or a parent element has role="presentation".
5468
+ *
5469
+ * Dynamic values yields `false` just as if the attribute wasn't present.
5470
+ */
5471
+ function isPresentation(node) {
5472
+ if (node.cacheExists(ROLE_PRESENTATION_CACHE)) {
5473
+ return Boolean(node.cacheGet(ROLE_PRESENTATION_CACHE));
5474
+ }
5475
+ let cur = node;
5476
+ do {
5477
+ const role = cur.getAttribute("role");
5478
+ /* role="presentation" */
5479
+ if (role && role.value === "presentation") {
5480
+ return cur.cacheSet(ROLE_PRESENTATION_CACHE, true);
5481
+ }
5482
+ /* sanity check: break if no parent is present, normally not an issue as the
5483
+ * root element should be found first */
5484
+ if (!cur.parent) {
5485
+ break;
5486
+ }
5487
+ /* check parents */
5488
+ cur = cur.parent;
5489
+ } while (!cur.isRootElement());
5490
+ return node.cacheSet(ROLE_PRESENTATION_CACHE, false);
5491
+ }
5492
+
5493
+ const cachePrefix = classifyNodeText.name;
5494
+ const HTML_CACHE_KEY = Symbol(`${cachePrefix}|html`);
5495
+ const A11Y_CACHE_KEY = Symbol(`${cachePrefix}|a11y`);
5496
+ const IGNORE_HIDDEN_ROOT_HTML_CACHE_KEY = Symbol(`${cachePrefix}|html|ignore-hidden-root`);
5497
+ const IGNORE_HIDDEN_ROOT_A11Y_CACHE_KEY = Symbol(`${cachePrefix}|a11y|ignore-hidden-root`);
5498
+ /**
5499
+ * @public
5500
+ */
5390
5501
  var TextClassification;
5391
5502
  (function (TextClassification) {
5392
5503
  TextClassification[TextClassification["EMPTY_TEXT"] = 0] = "EMPTY_TEXT";
5393
5504
  TextClassification[TextClassification["DYNAMIC_TEXT"] = 1] = "DYNAMIC_TEXT";
5394
5505
  TextClassification[TextClassification["STATIC_TEXT"] = 2] = "STATIC_TEXT";
5395
5506
  })(TextClassification || (TextClassification = {}));
5507
+ function getCachekey(options = {}) {
5508
+ const { accessible = false, ignoreHiddenRoot = false } = options;
5509
+ if (accessible && ignoreHiddenRoot) {
5510
+ return IGNORE_HIDDEN_ROOT_A11Y_CACHE_KEY;
5511
+ }
5512
+ else if (ignoreHiddenRoot) {
5513
+ return IGNORE_HIDDEN_ROOT_HTML_CACHE_KEY;
5514
+ }
5515
+ else if (accessible) {
5516
+ return A11Y_CACHE_KEY;
5517
+ }
5518
+ else {
5519
+ return HTML_CACHE_KEY;
5520
+ }
5521
+ }
5522
+ /* While I cannot find a reference about this in the standard the <select>
5523
+ * element kinda acts as if there is no text content, most particularly it
5524
+ * doesn't receive and accessible name. The `.textContent` property does
5525
+ * however include the <option> childrens text. But for the sake of the
5526
+ * validator it is probably best if the classification acts as if there is no
5527
+ * text as I think that is what is expected of the return values. Might have
5528
+ * to revisit this at some point or if someone could clarify what section of
5529
+ * the standard deals with this. */
5530
+ function isSpecialEmpty(node) {
5531
+ return node.is("select") || node.is("textarea");
5532
+ }
5396
5533
  /**
5397
5534
  * Checks text content of an element.
5398
5535
  *
@@ -5400,33 +5537,54 @@ var TextClassification;
5400
5537
  * ignored.
5401
5538
  *
5402
5539
  * If any text is dynamic `TextClassification.DYNAMIC_TEXT` is returned.
5540
+ *
5541
+ * @public
5403
5542
  */
5404
- function classifyNodeText(node) {
5405
- if (node.cacheExists(CACHE_KEY)) {
5406
- return node.cacheGet(CACHE_KEY);
5543
+ function classifyNodeText(node, options = {}) {
5544
+ const { accessible = false, ignoreHiddenRoot = false } = options;
5545
+ const cacheKey = getCachekey(options);
5546
+ if (node.cacheExists(cacheKey)) {
5547
+ return node.cacheGet(cacheKey);
5548
+ }
5549
+ if (!ignoreHiddenRoot && isHTMLHidden(node)) {
5550
+ return node.cacheSet(cacheKey, TextClassification.EMPTY_TEXT);
5551
+ }
5552
+ if (!ignoreHiddenRoot && accessible && isAriaHidden(node)) {
5553
+ return node.cacheSet(cacheKey, TextClassification.EMPTY_TEXT);
5407
5554
  }
5408
- const text = findTextNodes(node);
5555
+ if (isSpecialEmpty(node)) {
5556
+ return node.cacheSet(cacheKey, TextClassification.EMPTY_TEXT);
5557
+ }
5558
+ const text = findTextNodes(node, {
5559
+ ...options,
5560
+ ignoreHiddenRoot: false,
5561
+ });
5409
5562
  /* if any text is dynamic classify as dynamic */
5410
5563
  if (text.some((cur) => cur.isDynamic)) {
5411
- return node.cacheSet(CACHE_KEY, TextClassification.DYNAMIC_TEXT);
5564
+ return node.cacheSet(cacheKey, TextClassification.DYNAMIC_TEXT);
5412
5565
  }
5413
5566
  /* if any text has non-whitespace character classify as static */
5414
5567
  if (text.some((cur) => cur.textContent.match(/\S/) !== null)) {
5415
- return node.cacheSet(CACHE_KEY, TextClassification.STATIC_TEXT);
5568
+ return node.cacheSet(cacheKey, TextClassification.STATIC_TEXT);
5416
5569
  }
5417
5570
  /* default to empty */
5418
- return node.cacheSet(CACHE_KEY, TextClassification.EMPTY_TEXT);
5571
+ return node.cacheSet(cacheKey, TextClassification.EMPTY_TEXT);
5419
5572
  }
5420
- function findTextNodes(node) {
5573
+ function findTextNodes(node, options) {
5574
+ const { accessible = false } = options;
5421
5575
  let text = [];
5422
5576
  for (const child of node.childNodes) {
5423
- switch (child.nodeType) {
5424
- case NodeType.TEXT_NODE:
5425
- text.push(child);
5426
- break;
5427
- case NodeType.ELEMENT_NODE:
5428
- text = text.concat(findTextNodes(child));
5429
- break;
5577
+ if (isTextNode(child)) {
5578
+ text.push(child);
5579
+ }
5580
+ else if (isElementNode(child)) {
5581
+ if (isHTMLHidden(child, true).bySelf) {
5582
+ continue;
5583
+ }
5584
+ if (accessible && isAriaHidden(child, true).bySelf) {
5585
+ continue;
5586
+ }
5587
+ text = text.concat(findTextNodes(child, options));
5430
5588
  }
5431
5589
  }
5432
5590
  return text;
@@ -5702,6 +5860,17 @@ class ElementRequiredContent extends Rule {
5702
5860
  }
5703
5861
 
5704
5862
  const selector = ["h1", "h2", "h3", "h4", "h5", "h6"].join(",");
5863
+ function hasImgAltText$1(node) {
5864
+ if (node.is("img")) {
5865
+ return hasAltText(node);
5866
+ }
5867
+ else if (node.is("svg")) {
5868
+ return node.textContent.trim() !== "";
5869
+ }
5870
+ else {
5871
+ return false;
5872
+ }
5873
+ }
5705
5874
  class EmptyHeading extends Rule {
5706
5875
  documentation() {
5707
5876
  return {
@@ -5713,19 +5882,28 @@ class EmptyHeading extends Rule {
5713
5882
  this.on("dom:ready", ({ document }) => {
5714
5883
  const headings = document.querySelectorAll(selector);
5715
5884
  for (const heading of headings) {
5716
- switch (classifyNodeText(heading)) {
5717
- case TextClassification.DYNAMIC_TEXT:
5718
- case TextClassification.STATIC_TEXT:
5719
- /* have some text content, consider ok */
5720
- break;
5721
- case TextClassification.EMPTY_TEXT:
5722
- /* no content or whitespace only */
5723
- this.report(heading, `<${heading.tagName}> cannot be empty, must have text content`);
5724
- break;
5725
- }
5885
+ this.validateHeading(heading);
5726
5886
  }
5727
5887
  });
5728
5888
  }
5889
+ validateHeading(heading) {
5890
+ const images = heading.querySelectorAll("img, svg");
5891
+ for (const child of images) {
5892
+ if (hasImgAltText$1(child)) {
5893
+ return;
5894
+ }
5895
+ }
5896
+ switch (classifyNodeText(heading)) {
5897
+ case TextClassification.DYNAMIC_TEXT:
5898
+ case TextClassification.STATIC_TEXT:
5899
+ /* have some text content, consider ok */
5900
+ break;
5901
+ case TextClassification.EMPTY_TEXT:
5902
+ /* no content or whitespace only */
5903
+ this.report(heading, `<${heading.tagName}> cannot be empty, must have text content`);
5904
+ break;
5905
+ }
5906
+ }
5729
5907
  }
5730
5908
 
5731
5909
  class EmptyTitle extends Rule {
@@ -6146,104 +6324,148 @@ class InputAttributes extends Rule {
6146
6324
  }
6147
6325
  }
6148
6326
 
6149
- const ARIA_HIDDEN_CACHE = Symbol(isAriaHidden.name);
6150
- const HTML_HIDDEN_CACHE = Symbol(isHTMLHidden.name);
6151
- const ROLE_PRESENTATION_CACHE = Symbol(isPresentation.name);
6152
- /**
6153
- * Tests if this element is present in the accessibility tree.
6154
- *
6155
- * In practice it tests whenever the element or its parents has
6156
- * `role="presentation"` or `aria-hidden="false"`. Dynamic values counts as
6157
- * visible since the element might be in the visibility tree sometimes.
6158
- */
6159
- function inAccessibilityTree(node) {
6160
- return !isAriaHidden(node) && !isPresentation(node);
6327
+ const HAS_ACCESSIBLE_TEXT_CACHE = Symbol(hasAccessibleName.name);
6328
+ function isHidden(node, context) {
6329
+ const { reference } = context;
6330
+ if (reference && reference.isSameNode(node)) {
6331
+ return false;
6332
+ }
6333
+ else {
6334
+ return isHTMLHidden(node) || !inAccessibilityTree(node);
6335
+ }
6161
6336
  }
6162
- /**
6163
- * Tests if this element or an ancestor have `aria-hidden="true"`.
6164
- *
6165
- * Dynamic values yields `false` since the element will conditionally be in the
6166
- * accessibility tree and must fulfill it's conditions.
6167
- */
6168
- function isAriaHidden(node) {
6169
- if (node.cacheExists(ARIA_HIDDEN_CACHE)) {
6170
- return Boolean(node.cacheGet(ARIA_HIDDEN_CACHE));
6337
+ function hasImgAltText(node, context) {
6338
+ if (node.is("img")) {
6339
+ return hasAltText(node);
6171
6340
  }
6172
- let cur = node;
6173
- do {
6174
- const ariaHidden = cur.getAttribute("aria-hidden");
6175
- /* aria-hidden="true" */
6176
- if (ariaHidden && ariaHidden.value === "true") {
6177
- return cur.cacheSet(ARIA_HIDDEN_CACHE, true);
6178
- }
6179
- /* sanity check: break if no parent is present, normally not an issue as the
6180
- * root element should be found first */
6181
- if (!cur.parent) {
6182
- break;
6341
+ else if (node.is("svg")) {
6342
+ return node.textContent.trim() !== "";
6343
+ }
6344
+ else {
6345
+ for (const img of node.querySelectorAll("img, svg")) {
6346
+ const hasName = hasAccessibleNameImpl(img, context);
6347
+ if (hasName) {
6348
+ return true;
6349
+ }
6183
6350
  }
6184
- /* check parents */
6185
- cur = cur.parent;
6186
- } while (!cur.isRootElement());
6187
- return node.cacheSet(ARIA_HIDDEN_CACHE, false);
6351
+ return false;
6352
+ }
6353
+ }
6354
+ function hasLabel(node) {
6355
+ var _a;
6356
+ const value = (_a = node.getAttributeValue("aria-label")) !== null && _a !== void 0 ? _a : "";
6357
+ return Boolean(value.trim());
6358
+ }
6359
+ function isLabelledby(node, context) {
6360
+ const { document, reference } = context;
6361
+ /* if we already have resolved one level of reference we don't resolve another
6362
+ * level (as per accname step 2B) */
6363
+ if (reference) {
6364
+ return false;
6365
+ }
6366
+ const ariaLabelledby = node.ariaLabelledby;
6367
+ /* consider dynamic aria-labelledby as having a name as we cannot resolve it
6368
+ * so no way to prove correctness */
6369
+ if (ariaLabelledby instanceof DynamicValue) {
6370
+ return true;
6371
+ }
6372
+ /* ignore elements without aria-labelledby */
6373
+ if (ariaLabelledby === null) {
6374
+ return false;
6375
+ }
6376
+ return ariaLabelledby.some((id) => {
6377
+ const selector = generateIdSelector(id);
6378
+ return document.querySelectorAll(selector).some((child) => {
6379
+ return hasAccessibleNameImpl(child, {
6380
+ document,
6381
+ reference: child,
6382
+ });
6383
+ });
6384
+ });
6188
6385
  }
6189
6386
  /**
6190
- * Tests if this element or an ancestor have `hidden` attribute.
6387
+ * This algorithm is based on ["Accessible Name and Description Computation
6388
+ * 1.2"][accname] with some exceptions:
6389
+ *
6390
+ * It doesn't compute the actual name but only the presence of one, e.g. if a
6391
+ * non-empty flat string is present the algorithm terminates with a positive
6392
+ * result.
6191
6393
  *
6192
- * Dynamic values yields `false` since the element will conditionally be in the
6193
- * DOM tree and must fulfill it's conditions.
6394
+ * It takes some optimization shortcuts such as starting with step F as it
6395
+ * would be more common usage and as there is no actual name being computed
6396
+ * the order wont matter.
6397
+ *
6398
+ * [accname]: https://w3c.github.io/accname
6194
6399
  */
6195
- function isHTMLHidden(node) {
6196
- if (node.cacheExists(HTML_HIDDEN_CACHE)) {
6197
- return Boolean(node.cacheGet(HTML_HIDDEN_CACHE));
6400
+ function hasAccessibleNameImpl(current, context) {
6401
+ const { reference } = context;
6402
+ /* if this element is hidden (see function for exceptions) it does not have an accessible name */
6403
+ if (isHidden(current, context)) {
6404
+ return false;
6198
6405
  }
6199
- let cur = node;
6200
- do {
6201
- const hidden = cur.getAttribute("hidden");
6202
- /* hidden present */
6203
- if (hidden !== null && hidden.isStatic) {
6204
- return cur.cacheSet(HTML_HIDDEN_CACHE, true);
6205
- }
6206
- /* sanity check: break if no parent is present, normally not an issue as the
6207
- * root element should be found first */
6208
- if (!cur.parent) {
6209
- break;
6210
- }
6211
- /* check parents */
6212
- cur = cur.parent;
6213
- } while (!cur.isRootElement());
6214
- return node.cacheSet(HTML_HIDDEN_CACHE, false);
6406
+ /* special case: when this element is directly referenced by aria-labelledby
6407
+ * we ignore `hidden` */
6408
+ const ignoreHiddenRoot = Boolean(reference && reference.isSameNode(current));
6409
+ const text = classifyNodeText(current, { accessible: true, ignoreHiddenRoot });
6410
+ if (text !== TextClassification.EMPTY_TEXT) {
6411
+ return true;
6412
+ }
6413
+ if (hasImgAltText(current, context)) {
6414
+ return true;
6415
+ }
6416
+ if (hasLabel(current)) {
6417
+ return true;
6418
+ }
6419
+ if (isLabelledby(current, context)) {
6420
+ return true;
6421
+ }
6422
+ return false;
6215
6423
  }
6216
6424
  /**
6217
- * Tests if this element or a parent element has role="presentation".
6425
+ * Returns `true` if the element has an accessible name.
6218
6426
  *
6219
- * Dynamic values yields `false` just as if the attribute wasn't present.
6427
+ * It does not yet consider if the elements role prohibits naming, e.g. a `<p>`
6428
+ * element will still show up as having an accessible name.
6429
+ *
6430
+ * @public
6431
+ * @param document - Document element.
6432
+ * @param current - The element to get accessible name for
6433
+ * @returns `true` if the element has an accessible name.
6220
6434
  */
6221
- function isPresentation(node) {
6222
- if (node.cacheExists(ROLE_PRESENTATION_CACHE)) {
6223
- return Boolean(node.cacheGet(ROLE_PRESENTATION_CACHE));
6224
- }
6225
- let cur = node;
6226
- do {
6227
- const role = cur.getAttribute("role");
6228
- /* role="presentation" */
6229
- if (role && role.value === "presentation") {
6230
- return cur.cacheSet(ROLE_PRESENTATION_CACHE, true);
6231
- }
6232
- /* sanity check: break if no parent is present, normally not an issue as the
6233
- * root element should be found first */
6234
- if (!cur.parent) {
6235
- break;
6236
- }
6237
- /* check parents */
6238
- cur = cur.parent;
6239
- } while (!cur.isRootElement());
6240
- return node.cacheSet(ROLE_PRESENTATION_CACHE, false);
6435
+ function hasAccessibleName(document, current) {
6436
+ /* istanbul ignore next: we're not testing cache */
6437
+ if (current.cacheExists(HAS_ACCESSIBLE_TEXT_CACHE)) {
6438
+ return Boolean(current.cacheGet(HAS_ACCESSIBLE_TEXT_CACHE));
6439
+ }
6440
+ const result = hasAccessibleNameImpl(current, {
6441
+ document,
6442
+ reference: null,
6443
+ });
6444
+ return current.cacheSet(HAS_ACCESSIBLE_TEXT_CACHE, result);
6241
6445
  }
6242
6446
 
6447
+ function isIgnored(node) {
6448
+ var _a;
6449
+ if (node.is("input")) {
6450
+ const type = (_a = node.getAttributeValue("type")) === null || _a === void 0 ? void 0 : _a.toLowerCase();
6451
+ const ignored = ["hidden", "submit", "reset", "button"];
6452
+ return Boolean(type && ignored.includes(type));
6453
+ }
6454
+ return false;
6455
+ }
6243
6456
  class InputMissingLabel extends Rule {
6244
6457
  documentation() {
6245
6458
  return {
6246
- description: "Labels are associated with the input element and is required for a11y.",
6459
+ description: [
6460
+ "Each form element must have an a label or accessible name.",
6461
+ 'Typically this is implemented using a `<label for="..">` element describing the purpose of the form element.',
6462
+ "",
6463
+ "This can be resolved in one of the following ways:",
6464
+ "",
6465
+ ' - Use an associated `<label for="..">` element.',
6466
+ " - Use a nested `<label>` as parent element.",
6467
+ " - Use `aria-label` or `aria-labelledby` attributes.",
6468
+ ].join("\n"),
6247
6469
  url: ruleDocumentationUrl("@/rules/input-missing-label.ts"),
6248
6470
  };
6249
6471
  }
@@ -6256,38 +6478,48 @@ class InputMissingLabel extends Rule {
6256
6478
  });
6257
6479
  }
6258
6480
  validateInput(root, elem) {
6259
- var _a;
6260
6481
  if (isHTMLHidden(elem) || isAriaHidden(elem)) {
6261
6482
  return;
6262
6483
  }
6263
6484
  /* hidden, submit, reset or button should not have label */
6264
- if (elem.is("input")) {
6265
- const type = (_a = elem.getAttributeValue("type")) === null || _a === void 0 ? void 0 : _a.toLowerCase();
6266
- const ignored = ["hidden", "submit", "reset", "button"];
6267
- if (type && ignored.includes(type)) {
6268
- return;
6269
- }
6485
+ if (isIgnored(elem)) {
6486
+ return;
6487
+ }
6488
+ if (hasAccessibleName(root, elem)) {
6489
+ return;
6270
6490
  }
6271
6491
  let label = [];
6272
6492
  /* try to find label by id */
6273
6493
  if ((label = findLabelById(root, elem.id)).length > 0) {
6274
- this.validateLabel(elem, label);
6494
+ this.validateLabel(root, elem, label);
6275
6495
  return;
6276
6496
  }
6277
6497
  /* try to find parent label (input nested in label) */
6278
6498
  if ((label = findLabelByParent(elem)).length > 0) {
6279
- this.validateLabel(elem, label);
6499
+ this.validateLabel(root, elem, label);
6280
6500
  return;
6281
6501
  }
6282
- this.report(elem, `<${elem.tagName}> element does not have a <label>`);
6502
+ if (elem.hasAttribute("aria-label")) {
6503
+ this.report(elem, `<${elem.tagName}> element has aria-label but label has no text`);
6504
+ }
6505
+ else if (elem.hasAttribute("aria-labelledby")) {
6506
+ this.report(elem, `<${elem.tagName}> element has aria-labelledby but referenced element has no text`);
6507
+ }
6508
+ else {
6509
+ this.report(elem, `<${elem.tagName}> element does not have a <label>`);
6510
+ }
6283
6511
  }
6284
6512
  /**
6285
6513
  * Reports error if none of the labels are accessible.
6286
6514
  */
6287
- validateLabel(elem, labels) {
6515
+ validateLabel(root, elem, labels) {
6288
6516
  const visible = labels.filter(isVisible);
6289
6517
  if (visible.length === 0) {
6290
- this.report(elem, `<${elem.tagName}> element has label but <label> element is hidden`);
6518
+ this.report(elem, `<${elem.tagName}> element has <label> but <label> element is hidden`);
6519
+ return;
6520
+ }
6521
+ if (!labels.some((label) => hasAccessibleName(root, label))) {
6522
+ this.report(elem, `<${elem.tagName}> element has <label> but <label> has no text`);
6291
6523
  }
6292
6524
  }
6293
6525
  }
@@ -13095,5 +13327,5 @@ function getFormatter(name) {
13095
13327
  return (_a = availableFormatters[name]) !== null && _a !== void 0 ? _a : null;
13096
13328
  }
13097
13329
 
13098
- export { Config as C, DynamicValue as D, EventHandler as E, FileSystemConfigLoader as F, HtmlValidate as H, MetaTable as M, NodeClosed as N, Parser as P, Rule as R, Severity as S, TextNode as T, UserError as U, WrappedError as W, ConfigError as a, ConfigLoader as b, StaticConfigLoader as c, HtmlElement as d, SchemaValidationError as e, NestedError as f, MetaCopyableProperty as g, Reporter as h, TemplateExtractor as i, getFormatter as j, ensureError as k, legacyRequire as l, configDataFromFile as m, compatibilityCheck as n, codeframe as o, presets as p, name as q, ruleExists as r, bugs as s, version as v };
13330
+ export { Config as C, DynamicValue as D, EventHandler as E, FileSystemConfigLoader as F, HtmlValidate as H, MetaTable as M, NodeClosed as N, Parser as P, Rule as R, Severity as S, TextNode as T, UserError as U, WrappedError as W, ConfigError as a, ConfigLoader as b, StaticConfigLoader as c, HtmlElement as d, SchemaValidationError as e, NestedError as f, MetaCopyableProperty as g, classifyNodeText as h, TextClassification as i, Reporter as j, TemplateExtractor as k, getFormatter as l, legacyRequire as m, ensureError as n, configDataFromFile as o, presets as p, compatibilityCheck as q, ruleExists as r, codeframe as s, name as t, bugs as u, version as v };
13099
13331
  //# sourceMappingURL=core.js.map