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/cjs/core.js CHANGED
@@ -1767,9 +1767,19 @@ function factory(name, context) {
1767
1767
  function stripslashes(value) {
1768
1768
  return value.replace(/\\(.)/g, "$1");
1769
1769
  }
1770
+ /**
1771
+ * @internal
1772
+ */
1770
1773
  function escapeSelectorComponent(text) {
1771
1774
  return text.toString().replace(/([^a-z0-9_-])/gi, "\\$1");
1772
1775
  }
1776
+ /**
1777
+ * @internal
1778
+ */
1779
+ function generateIdSelector(id) {
1780
+ const escaped = escapeSelectorComponent(id);
1781
+ return escaped.match(/^\d/) ? `[id="${escaped}"]` : `#${escaped}`;
1782
+ }
1773
1783
  /**
1774
1784
  * Returns true if the character is a delimiter for different kinds of selectors:
1775
1785
  *
@@ -2146,6 +2156,25 @@ class HtmlElement extends DOMNode {
2146
2156
  return `<${this.tagName}>`;
2147
2157
  }
2148
2158
  }
2159
+ /**
2160
+ * Get list of IDs referenced by `aria-labelledby`.
2161
+ *
2162
+ * If the attribute is unset or empty this getter returns null.
2163
+ * If the attribute is dynamic the original {@link DynamicValue} is returned.
2164
+ *
2165
+ * @public
2166
+ */
2167
+ get ariaLabelledby() {
2168
+ const attr = this.getAttribute("aria-labelledby");
2169
+ if (!attr || !attr.value) {
2170
+ return null;
2171
+ }
2172
+ if (attr.value instanceof DynamicValue) {
2173
+ return attr.value;
2174
+ }
2175
+ const list = new DOMTokenList(attr.value, attr.valueLocation);
2176
+ return list.length ? Array.from(list) : null;
2177
+ }
2149
2178
  /**
2150
2179
  * Similar to childNodes but only elements.
2151
2180
  */
@@ -2187,8 +2216,7 @@ class HtmlElement extends DOMNode {
2187
2216
  for (let cur = this; cur.parent; cur = cur.parent) {
2188
2217
  /* if a unique id is present, use it and short-circuit */
2189
2218
  if (cur.id) {
2190
- const escaped = escapeSelectorComponent(cur.id);
2191
- const selector = escaped.match(/^\d/) ? `[id="${escaped}"]` : `#${escaped}`;
2219
+ const selector = generateIdSelector(cur.id);
2192
2220
  const matches = root.querySelectorAll(selector);
2193
2221
  if (matches.length === 1) {
2194
2222
  parts.push(selector);
@@ -3194,7 +3222,7 @@ var TRANSFORMER_API;
3194
3222
  /** @public */
3195
3223
  const name = "html-validate";
3196
3224
  /** @public */
3197
- const version = "7.5.0";
3225
+ const version = "7.6.0";
3198
3226
  /** @public */
3199
3227
  const homepage = "https://html-validate.org";
3200
3228
  /** @public */
@@ -5417,13 +5445,122 @@ class ElementPermittedOrder extends Rule {
5417
5445
  }
5418
5446
  }
5419
5447
 
5420
- const CACHE_KEY = Symbol(classifyNodeText.name);
5421
- var TextClassification;
5448
+ const ARIA_HIDDEN_CACHE = Symbol(isAriaHidden.name);
5449
+ const HTML_HIDDEN_CACHE = Symbol(isHTMLHidden.name);
5450
+ const ROLE_PRESENTATION_CACHE = Symbol(isPresentation.name);
5451
+ /**
5452
+ * Tests if this element is present in the accessibility tree.
5453
+ *
5454
+ * In practice it tests whenever the element or its parents has
5455
+ * `role="presentation"` or `aria-hidden="false"`. Dynamic values counts as
5456
+ * visible since the element might be in the visibility tree sometimes.
5457
+ */
5458
+ function inAccessibilityTree(node) {
5459
+ return !isAriaHidden(node) && !isPresentation(node);
5460
+ }
5461
+ function isAriaHiddenImpl(node) {
5462
+ const isHidden = (node) => {
5463
+ const ariaHidden = node.getAttribute("aria-hidden");
5464
+ return Boolean(ariaHidden && ariaHidden.value === "true");
5465
+ };
5466
+ return {
5467
+ byParent: node.parent ? isAriaHidden(node.parent) : false,
5468
+ bySelf: isHidden(node),
5469
+ };
5470
+ }
5471
+ function isAriaHidden(node, details) {
5472
+ const cached = node.cacheGet(ARIA_HIDDEN_CACHE);
5473
+ if (cached) {
5474
+ return details ? cached : cached.byParent || cached.bySelf;
5475
+ }
5476
+ const result = node.cacheSet(ARIA_HIDDEN_CACHE, isAriaHiddenImpl(node));
5477
+ return details ? result : result.byParent || result.bySelf;
5478
+ }
5479
+ function isHTMLHiddenImpl(node) {
5480
+ const isHidden = (node) => {
5481
+ const hidden = node.getAttribute("hidden");
5482
+ return hidden !== null && hidden.isStatic;
5483
+ };
5484
+ return {
5485
+ byParent: node.parent ? isHTMLHidden(node.parent) : false,
5486
+ bySelf: isHidden(node),
5487
+ };
5488
+ }
5489
+ function isHTMLHidden(node, details) {
5490
+ const cached = node.cacheGet(HTML_HIDDEN_CACHE);
5491
+ if (cached) {
5492
+ return details ? cached : cached.byParent || cached.bySelf;
5493
+ }
5494
+ const result = node.cacheSet(HTML_HIDDEN_CACHE, isHTMLHiddenImpl(node));
5495
+ return details ? result : result.byParent || result.bySelf;
5496
+ }
5497
+ /**
5498
+ * Tests if this element or a parent element has role="presentation".
5499
+ *
5500
+ * Dynamic values yields `false` just as if the attribute wasn't present.
5501
+ */
5502
+ function isPresentation(node) {
5503
+ if (node.cacheExists(ROLE_PRESENTATION_CACHE)) {
5504
+ return Boolean(node.cacheGet(ROLE_PRESENTATION_CACHE));
5505
+ }
5506
+ let cur = node;
5507
+ do {
5508
+ const role = cur.getAttribute("role");
5509
+ /* role="presentation" */
5510
+ if (role && role.value === "presentation") {
5511
+ return cur.cacheSet(ROLE_PRESENTATION_CACHE, true);
5512
+ }
5513
+ /* sanity check: break if no parent is present, normally not an issue as the
5514
+ * root element should be found first */
5515
+ if (!cur.parent) {
5516
+ break;
5517
+ }
5518
+ /* check parents */
5519
+ cur = cur.parent;
5520
+ } while (!cur.isRootElement());
5521
+ return node.cacheSet(ROLE_PRESENTATION_CACHE, false);
5522
+ }
5523
+
5524
+ const cachePrefix = classifyNodeText.name;
5525
+ const HTML_CACHE_KEY = Symbol(`${cachePrefix}|html`);
5526
+ const A11Y_CACHE_KEY = Symbol(`${cachePrefix}|a11y`);
5527
+ const IGNORE_HIDDEN_ROOT_HTML_CACHE_KEY = Symbol(`${cachePrefix}|html|ignore-hidden-root`);
5528
+ const IGNORE_HIDDEN_ROOT_A11Y_CACHE_KEY = Symbol(`${cachePrefix}|a11y|ignore-hidden-root`);
5529
+ /**
5530
+ * @public
5531
+ */
5532
+ exports.TextClassification = void 0;
5422
5533
  (function (TextClassification) {
5423
5534
  TextClassification[TextClassification["EMPTY_TEXT"] = 0] = "EMPTY_TEXT";
5424
5535
  TextClassification[TextClassification["DYNAMIC_TEXT"] = 1] = "DYNAMIC_TEXT";
5425
5536
  TextClassification[TextClassification["STATIC_TEXT"] = 2] = "STATIC_TEXT";
5426
- })(TextClassification || (TextClassification = {}));
5537
+ })(exports.TextClassification || (exports.TextClassification = {}));
5538
+ function getCachekey(options = {}) {
5539
+ const { accessible = false, ignoreHiddenRoot = false } = options;
5540
+ if (accessible && ignoreHiddenRoot) {
5541
+ return IGNORE_HIDDEN_ROOT_A11Y_CACHE_KEY;
5542
+ }
5543
+ else if (ignoreHiddenRoot) {
5544
+ return IGNORE_HIDDEN_ROOT_HTML_CACHE_KEY;
5545
+ }
5546
+ else if (accessible) {
5547
+ return A11Y_CACHE_KEY;
5548
+ }
5549
+ else {
5550
+ return HTML_CACHE_KEY;
5551
+ }
5552
+ }
5553
+ /* While I cannot find a reference about this in the standard the <select>
5554
+ * element kinda acts as if there is no text content, most particularly it
5555
+ * doesn't receive and accessible name. The `.textContent` property does
5556
+ * however include the <option> childrens text. But for the sake of the
5557
+ * validator it is probably best if the classification acts as if there is no
5558
+ * text as I think that is what is expected of the return values. Might have
5559
+ * to revisit this at some point or if someone could clarify what section of
5560
+ * the standard deals with this. */
5561
+ function isSpecialEmpty(node) {
5562
+ return node.is("select") || node.is("textarea");
5563
+ }
5427
5564
  /**
5428
5565
  * Checks text content of an element.
5429
5566
  *
@@ -5431,33 +5568,54 @@ var TextClassification;
5431
5568
  * ignored.
5432
5569
  *
5433
5570
  * If any text is dynamic `TextClassification.DYNAMIC_TEXT` is returned.
5571
+ *
5572
+ * @public
5434
5573
  */
5435
- function classifyNodeText(node) {
5436
- if (node.cacheExists(CACHE_KEY)) {
5437
- return node.cacheGet(CACHE_KEY);
5574
+ function classifyNodeText(node, options = {}) {
5575
+ const { accessible = false, ignoreHiddenRoot = false } = options;
5576
+ const cacheKey = getCachekey(options);
5577
+ if (node.cacheExists(cacheKey)) {
5578
+ return node.cacheGet(cacheKey);
5579
+ }
5580
+ if (!ignoreHiddenRoot && isHTMLHidden(node)) {
5581
+ return node.cacheSet(cacheKey, exports.TextClassification.EMPTY_TEXT);
5582
+ }
5583
+ if (!ignoreHiddenRoot && accessible && isAriaHidden(node)) {
5584
+ return node.cacheSet(cacheKey, exports.TextClassification.EMPTY_TEXT);
5438
5585
  }
5439
- const text = findTextNodes(node);
5586
+ if (isSpecialEmpty(node)) {
5587
+ return node.cacheSet(cacheKey, exports.TextClassification.EMPTY_TEXT);
5588
+ }
5589
+ const text = findTextNodes(node, {
5590
+ ...options,
5591
+ ignoreHiddenRoot: false,
5592
+ });
5440
5593
  /* if any text is dynamic classify as dynamic */
5441
5594
  if (text.some((cur) => cur.isDynamic)) {
5442
- return node.cacheSet(CACHE_KEY, TextClassification.DYNAMIC_TEXT);
5595
+ return node.cacheSet(cacheKey, exports.TextClassification.DYNAMIC_TEXT);
5443
5596
  }
5444
5597
  /* if any text has non-whitespace character classify as static */
5445
5598
  if (text.some((cur) => cur.textContent.match(/\S/) !== null)) {
5446
- return node.cacheSet(CACHE_KEY, TextClassification.STATIC_TEXT);
5599
+ return node.cacheSet(cacheKey, exports.TextClassification.STATIC_TEXT);
5447
5600
  }
5448
5601
  /* default to empty */
5449
- return node.cacheSet(CACHE_KEY, TextClassification.EMPTY_TEXT);
5602
+ return node.cacheSet(cacheKey, exports.TextClassification.EMPTY_TEXT);
5450
5603
  }
5451
- function findTextNodes(node) {
5604
+ function findTextNodes(node, options) {
5605
+ const { accessible = false } = options;
5452
5606
  let text = [];
5453
5607
  for (const child of node.childNodes) {
5454
- switch (child.nodeType) {
5455
- case NodeType.TEXT_NODE:
5456
- text.push(child);
5457
- break;
5458
- case NodeType.ELEMENT_NODE:
5459
- text = text.concat(findTextNodes(child));
5460
- break;
5608
+ if (isTextNode(child)) {
5609
+ text.push(child);
5610
+ }
5611
+ else if (isElementNode(child)) {
5612
+ if (isHTMLHidden(child, true).bySelf) {
5613
+ continue;
5614
+ }
5615
+ if (accessible && isAriaHidden(child, true).bySelf) {
5616
+ continue;
5617
+ }
5618
+ text = text.concat(findTextNodes(child, options));
5461
5619
  }
5462
5620
  }
5463
5621
  return text;
@@ -5733,6 +5891,17 @@ class ElementRequiredContent extends Rule {
5733
5891
  }
5734
5892
 
5735
5893
  const selector = ["h1", "h2", "h3", "h4", "h5", "h6"].join(",");
5894
+ function hasImgAltText$1(node) {
5895
+ if (node.is("img")) {
5896
+ return hasAltText(node);
5897
+ }
5898
+ else if (node.is("svg")) {
5899
+ return node.textContent.trim() !== "";
5900
+ }
5901
+ else {
5902
+ return false;
5903
+ }
5904
+ }
5736
5905
  class EmptyHeading extends Rule {
5737
5906
  documentation() {
5738
5907
  return {
@@ -5744,19 +5913,28 @@ class EmptyHeading extends Rule {
5744
5913
  this.on("dom:ready", ({ document }) => {
5745
5914
  const headings = document.querySelectorAll(selector);
5746
5915
  for (const heading of headings) {
5747
- switch (classifyNodeText(heading)) {
5748
- case TextClassification.DYNAMIC_TEXT:
5749
- case TextClassification.STATIC_TEXT:
5750
- /* have some text content, consider ok */
5751
- break;
5752
- case TextClassification.EMPTY_TEXT:
5753
- /* no content or whitespace only */
5754
- this.report(heading, `<${heading.tagName}> cannot be empty, must have text content`);
5755
- break;
5756
- }
5916
+ this.validateHeading(heading);
5757
5917
  }
5758
5918
  });
5759
5919
  }
5920
+ validateHeading(heading) {
5921
+ const images = heading.querySelectorAll("img, svg");
5922
+ for (const child of images) {
5923
+ if (hasImgAltText$1(child)) {
5924
+ return;
5925
+ }
5926
+ }
5927
+ switch (classifyNodeText(heading)) {
5928
+ case exports.TextClassification.DYNAMIC_TEXT:
5929
+ case exports.TextClassification.STATIC_TEXT:
5930
+ /* have some text content, consider ok */
5931
+ break;
5932
+ case exports.TextClassification.EMPTY_TEXT:
5933
+ /* no content or whitespace only */
5934
+ this.report(heading, `<${heading.tagName}> cannot be empty, must have text content`);
5935
+ break;
5936
+ }
5937
+ }
5760
5938
  }
5761
5939
 
5762
5940
  class EmptyTitle extends Rule {
@@ -5779,11 +5957,11 @@ class EmptyTitle extends Rule {
5779
5957
  if (node.tagName !== "title")
5780
5958
  return;
5781
5959
  switch (classifyNodeText(node)) {
5782
- case TextClassification.DYNAMIC_TEXT:
5783
- case TextClassification.STATIC_TEXT:
5960
+ case exports.TextClassification.DYNAMIC_TEXT:
5961
+ case exports.TextClassification.STATIC_TEXT:
5784
5962
  /* have some text content, consider ok */
5785
5963
  break;
5786
- case TextClassification.EMPTY_TEXT:
5964
+ case exports.TextClassification.EMPTY_TEXT:
5787
5965
  /* no content or whitespace only */
5788
5966
  {
5789
5967
  const message = `<${node.tagName}> cannot be empty, must have text content`;
@@ -6177,104 +6355,148 @@ class InputAttributes extends Rule {
6177
6355
  }
6178
6356
  }
6179
6357
 
6180
- const ARIA_HIDDEN_CACHE = Symbol(isAriaHidden.name);
6181
- const HTML_HIDDEN_CACHE = Symbol(isHTMLHidden.name);
6182
- const ROLE_PRESENTATION_CACHE = Symbol(isPresentation.name);
6183
- /**
6184
- * Tests if this element is present in the accessibility tree.
6185
- *
6186
- * In practice it tests whenever the element or its parents has
6187
- * `role="presentation"` or `aria-hidden="false"`. Dynamic values counts as
6188
- * visible since the element might be in the visibility tree sometimes.
6189
- */
6190
- function inAccessibilityTree(node) {
6191
- return !isAriaHidden(node) && !isPresentation(node);
6358
+ const HAS_ACCESSIBLE_TEXT_CACHE = Symbol(hasAccessibleName.name);
6359
+ function isHidden(node, context) {
6360
+ const { reference } = context;
6361
+ if (reference && reference.isSameNode(node)) {
6362
+ return false;
6363
+ }
6364
+ else {
6365
+ return isHTMLHidden(node) || !inAccessibilityTree(node);
6366
+ }
6192
6367
  }
6193
- /**
6194
- * Tests if this element or an ancestor have `aria-hidden="true"`.
6195
- *
6196
- * Dynamic values yields `false` since the element will conditionally be in the
6197
- * accessibility tree and must fulfill it's conditions.
6198
- */
6199
- function isAriaHidden(node) {
6200
- if (node.cacheExists(ARIA_HIDDEN_CACHE)) {
6201
- return Boolean(node.cacheGet(ARIA_HIDDEN_CACHE));
6368
+ function hasImgAltText(node, context) {
6369
+ if (node.is("img")) {
6370
+ return hasAltText(node);
6202
6371
  }
6203
- let cur = node;
6204
- do {
6205
- const ariaHidden = cur.getAttribute("aria-hidden");
6206
- /* aria-hidden="true" */
6207
- if (ariaHidden && ariaHidden.value === "true") {
6208
- return cur.cacheSet(ARIA_HIDDEN_CACHE, true);
6209
- }
6210
- /* sanity check: break if no parent is present, normally not an issue as the
6211
- * root element should be found first */
6212
- if (!cur.parent) {
6213
- break;
6372
+ else if (node.is("svg")) {
6373
+ return node.textContent.trim() !== "";
6374
+ }
6375
+ else {
6376
+ for (const img of node.querySelectorAll("img, svg")) {
6377
+ const hasName = hasAccessibleNameImpl(img, context);
6378
+ if (hasName) {
6379
+ return true;
6380
+ }
6214
6381
  }
6215
- /* check parents */
6216
- cur = cur.parent;
6217
- } while (!cur.isRootElement());
6218
- return node.cacheSet(ARIA_HIDDEN_CACHE, false);
6382
+ return false;
6383
+ }
6384
+ }
6385
+ function hasLabel(node) {
6386
+ var _a;
6387
+ const value = (_a = node.getAttributeValue("aria-label")) !== null && _a !== void 0 ? _a : "";
6388
+ return Boolean(value.trim());
6389
+ }
6390
+ function isLabelledby(node, context) {
6391
+ const { document, reference } = context;
6392
+ /* if we already have resolved one level of reference we don't resolve another
6393
+ * level (as per accname step 2B) */
6394
+ if (reference) {
6395
+ return false;
6396
+ }
6397
+ const ariaLabelledby = node.ariaLabelledby;
6398
+ /* consider dynamic aria-labelledby as having a name as we cannot resolve it
6399
+ * so no way to prove correctness */
6400
+ if (ariaLabelledby instanceof DynamicValue) {
6401
+ return true;
6402
+ }
6403
+ /* ignore elements without aria-labelledby */
6404
+ if (ariaLabelledby === null) {
6405
+ return false;
6406
+ }
6407
+ return ariaLabelledby.some((id) => {
6408
+ const selector = generateIdSelector(id);
6409
+ return document.querySelectorAll(selector).some((child) => {
6410
+ return hasAccessibleNameImpl(child, {
6411
+ document,
6412
+ reference: child,
6413
+ });
6414
+ });
6415
+ });
6219
6416
  }
6220
6417
  /**
6221
- * Tests if this element or an ancestor have `hidden` attribute.
6418
+ * This algorithm is based on ["Accessible Name and Description Computation
6419
+ * 1.2"][accname] with some exceptions:
6420
+ *
6421
+ * It doesn't compute the actual name but only the presence of one, e.g. if a
6422
+ * non-empty flat string is present the algorithm terminates with a positive
6423
+ * result.
6222
6424
  *
6223
- * Dynamic values yields `false` since the element will conditionally be in the
6224
- * DOM tree and must fulfill it's conditions.
6425
+ * It takes some optimization shortcuts such as starting with step F as it
6426
+ * would be more common usage and as there is no actual name being computed
6427
+ * the order wont matter.
6428
+ *
6429
+ * [accname]: https://w3c.github.io/accname
6225
6430
  */
6226
- function isHTMLHidden(node) {
6227
- if (node.cacheExists(HTML_HIDDEN_CACHE)) {
6228
- return Boolean(node.cacheGet(HTML_HIDDEN_CACHE));
6431
+ function hasAccessibleNameImpl(current, context) {
6432
+ const { reference } = context;
6433
+ /* if this element is hidden (see function for exceptions) it does not have an accessible name */
6434
+ if (isHidden(current, context)) {
6435
+ return false;
6229
6436
  }
6230
- let cur = node;
6231
- do {
6232
- const hidden = cur.getAttribute("hidden");
6233
- /* hidden present */
6234
- if (hidden !== null && hidden.isStatic) {
6235
- return cur.cacheSet(HTML_HIDDEN_CACHE, true);
6236
- }
6237
- /* sanity check: break if no parent is present, normally not an issue as the
6238
- * root element should be found first */
6239
- if (!cur.parent) {
6240
- break;
6241
- }
6242
- /* check parents */
6243
- cur = cur.parent;
6244
- } while (!cur.isRootElement());
6245
- return node.cacheSet(HTML_HIDDEN_CACHE, false);
6437
+ /* special case: when this element is directly referenced by aria-labelledby
6438
+ * we ignore `hidden` */
6439
+ const ignoreHiddenRoot = Boolean(reference && reference.isSameNode(current));
6440
+ const text = classifyNodeText(current, { accessible: true, ignoreHiddenRoot });
6441
+ if (text !== exports.TextClassification.EMPTY_TEXT) {
6442
+ return true;
6443
+ }
6444
+ if (hasImgAltText(current, context)) {
6445
+ return true;
6446
+ }
6447
+ if (hasLabel(current)) {
6448
+ return true;
6449
+ }
6450
+ if (isLabelledby(current, context)) {
6451
+ return true;
6452
+ }
6453
+ return false;
6246
6454
  }
6247
6455
  /**
6248
- * Tests if this element or a parent element has role="presentation".
6456
+ * Returns `true` if the element has an accessible name.
6249
6457
  *
6250
- * Dynamic values yields `false` just as if the attribute wasn't present.
6458
+ * It does not yet consider if the elements role prohibits naming, e.g. a `<p>`
6459
+ * element will still show up as having an accessible name.
6460
+ *
6461
+ * @public
6462
+ * @param document - Document element.
6463
+ * @param current - The element to get accessible name for
6464
+ * @returns `true` if the element has an accessible name.
6251
6465
  */
6252
- function isPresentation(node) {
6253
- if (node.cacheExists(ROLE_PRESENTATION_CACHE)) {
6254
- return Boolean(node.cacheGet(ROLE_PRESENTATION_CACHE));
6255
- }
6256
- let cur = node;
6257
- do {
6258
- const role = cur.getAttribute("role");
6259
- /* role="presentation" */
6260
- if (role && role.value === "presentation") {
6261
- return cur.cacheSet(ROLE_PRESENTATION_CACHE, true);
6262
- }
6263
- /* sanity check: break if no parent is present, normally not an issue as the
6264
- * root element should be found first */
6265
- if (!cur.parent) {
6266
- break;
6267
- }
6268
- /* check parents */
6269
- cur = cur.parent;
6270
- } while (!cur.isRootElement());
6271
- return node.cacheSet(ROLE_PRESENTATION_CACHE, false);
6466
+ function hasAccessibleName(document, current) {
6467
+ /* istanbul ignore next: we're not testing cache */
6468
+ if (current.cacheExists(HAS_ACCESSIBLE_TEXT_CACHE)) {
6469
+ return Boolean(current.cacheGet(HAS_ACCESSIBLE_TEXT_CACHE));
6470
+ }
6471
+ const result = hasAccessibleNameImpl(current, {
6472
+ document,
6473
+ reference: null,
6474
+ });
6475
+ return current.cacheSet(HAS_ACCESSIBLE_TEXT_CACHE, result);
6272
6476
  }
6273
6477
 
6478
+ function isIgnored(node) {
6479
+ var _a;
6480
+ if (node.is("input")) {
6481
+ const type = (_a = node.getAttributeValue("type")) === null || _a === void 0 ? void 0 : _a.toLowerCase();
6482
+ const ignored = ["hidden", "submit", "reset", "button"];
6483
+ return Boolean(type && ignored.includes(type));
6484
+ }
6485
+ return false;
6486
+ }
6274
6487
  class InputMissingLabel extends Rule {
6275
6488
  documentation() {
6276
6489
  return {
6277
- description: "Labels are associated with the input element and is required for a11y.",
6490
+ description: [
6491
+ "Each form element must have an a label or accessible name.",
6492
+ 'Typically this is implemented using a `<label for="..">` element describing the purpose of the form element.',
6493
+ "",
6494
+ "This can be resolved in one of the following ways:",
6495
+ "",
6496
+ ' - Use an associated `<label for="..">` element.',
6497
+ " - Use a nested `<label>` as parent element.",
6498
+ " - Use `aria-label` or `aria-labelledby` attributes.",
6499
+ ].join("\n"),
6278
6500
  url: ruleDocumentationUrl("@/rules/input-missing-label.ts"),
6279
6501
  };
6280
6502
  }
@@ -6287,38 +6509,48 @@ class InputMissingLabel extends Rule {
6287
6509
  });
6288
6510
  }
6289
6511
  validateInput(root, elem) {
6290
- var _a;
6291
6512
  if (isHTMLHidden(elem) || isAriaHidden(elem)) {
6292
6513
  return;
6293
6514
  }
6294
6515
  /* hidden, submit, reset or button should not have label */
6295
- if (elem.is("input")) {
6296
- const type = (_a = elem.getAttributeValue("type")) === null || _a === void 0 ? void 0 : _a.toLowerCase();
6297
- const ignored = ["hidden", "submit", "reset", "button"];
6298
- if (type && ignored.includes(type)) {
6299
- return;
6300
- }
6516
+ if (isIgnored(elem)) {
6517
+ return;
6518
+ }
6519
+ if (hasAccessibleName(root, elem)) {
6520
+ return;
6301
6521
  }
6302
6522
  let label = [];
6303
6523
  /* try to find label by id */
6304
6524
  if ((label = findLabelById(root, elem.id)).length > 0) {
6305
- this.validateLabel(elem, label);
6525
+ this.validateLabel(root, elem, label);
6306
6526
  return;
6307
6527
  }
6308
6528
  /* try to find parent label (input nested in label) */
6309
6529
  if ((label = findLabelByParent(elem)).length > 0) {
6310
- this.validateLabel(elem, label);
6530
+ this.validateLabel(root, elem, label);
6311
6531
  return;
6312
6532
  }
6313
- this.report(elem, `<${elem.tagName}> element does not have a <label>`);
6533
+ if (elem.hasAttribute("aria-label")) {
6534
+ this.report(elem, `<${elem.tagName}> element has aria-label but label has no text`);
6535
+ }
6536
+ else if (elem.hasAttribute("aria-labelledby")) {
6537
+ this.report(elem, `<${elem.tagName}> element has aria-labelledby but referenced element has no text`);
6538
+ }
6539
+ else {
6540
+ this.report(elem, `<${elem.tagName}> element does not have a <label>`);
6541
+ }
6314
6542
  }
6315
6543
  /**
6316
6544
  * Reports error if none of the labels are accessible.
6317
6545
  */
6318
- validateLabel(elem, labels) {
6546
+ validateLabel(root, elem, labels) {
6319
6547
  const visible = labels.filter(isVisible);
6320
6548
  if (visible.length === 0) {
6321
- this.report(elem, `<${elem.tagName}> element has label but <label> element is hidden`);
6549
+ this.report(elem, `<${elem.tagName}> element has <label> but <label> element is hidden`);
6550
+ return;
6551
+ }
6552
+ if (!labels.some((label) => hasAccessibleName(root, label))) {
6553
+ this.report(elem, `<${elem.tagName}> element has <label> but <label> has no text`);
6322
6554
  }
6323
6555
  }
6324
6556
  }
@@ -8038,7 +8270,7 @@ class TextContent extends Rule {
8038
8270
  * Validate element has empty text (inter-element whitespace is not considered text)
8039
8271
  */
8040
8272
  validateNone(node) {
8041
- if (classifyNodeText(node) === TextClassification.EMPTY_TEXT) {
8273
+ if (classifyNodeText(node) === exports.TextClassification.EMPTY_TEXT) {
8042
8274
  return;
8043
8275
  }
8044
8276
  this.reportError(node, node.meta, `${node.annotatedName} must not have text content`);
@@ -8047,7 +8279,7 @@ class TextContent extends Rule {
8047
8279
  * Validate element has any text (inter-element whitespace is not considered text)
8048
8280
  */
8049
8281
  validateRequired(node) {
8050
- if (classifyNodeText(node) !== TextClassification.EMPTY_TEXT) {
8282
+ if (classifyNodeText(node) !== exports.TextClassification.EMPTY_TEXT) {
8051
8283
  return;
8052
8284
  }
8053
8285
  this.reportError(node, node.meta, `${node.annotatedName} must have text content`);
@@ -10152,7 +10384,7 @@ class H30 extends Rule {
10152
10384
  }
10153
10385
  /* check if text content is present (or dynamic) */
10154
10386
  const textClassification = classifyNodeText(link);
10155
- if (textClassification !== TextClassification.EMPTY_TEXT) {
10387
+ if (textClassification !== exports.TextClassification.EMPTY_TEXT) {
10156
10388
  continue;
10157
10389
  }
10158
10390
  /* check if image with alt-text is present */
@@ -13147,6 +13379,7 @@ exports.TextNode = TextNode;
13147
13379
  exports.UserError = UserError;
13148
13380
  exports.WrappedError = WrappedError;
13149
13381
  exports.bugs = bugs;
13382
+ exports.classifyNodeText = classifyNodeText;
13150
13383
  exports.codeframe = codeframe;
13151
13384
  exports.compatibilityCheck = compatibilityCheck;
13152
13385
  exports.configDataFromFile = configDataFromFile;