All files / packages/sds-customization-compliance/src/checks/structural no-pseudo-state-hook-writes.ts

100% Statements 32/32
85.71% Branches 12/14
100% Functions 5/5
100% Lines 29/29

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109                                                        6x 6x 6x     6x 6x 6x               8x     9x           9x 8x 8x     6x 22x 22x 18x 3x     15x   15x 19x 15x 15x         11x 8x 8x 8x     8x                   15x 7x               8x                  
/**
 * Rule 3: no customer hook writes inside pseudo-state rules.
 *
 * Per-state hooks (`--slds-c-button-color-background-hover`, etc.) must
 * be **defined** on the idle/root selector and **read** by the
 * pseudo-state rule via a `var()` chain that falls back to the idle
 * hook. Writing the per-state hook *inside* the pseudo-state selector
 * (`.slds-button:hover { --slds-c-...-background-hover: ... }`) creates
 * a "wall": the customer's override on `.slds-button` for the hover
 * hook is overridden by this in-pseudo-state assignment, because cascade
 * specificity favors the more-specific selector.
 *
 * The fix is mechanical: hoist every per-state hook write to the idle
 * selector. The `:hover` rule then reads the hook normally and inherits
 * the customer override.
 */
 
import { classifySelector, targetsBemModifier } from '../helpers/selectorIssues.js';
import type { ComplianceCheck, ComplianceRow, Offender } from '../../types.js';
import {
  declLocation,
  failRow,
  notRunYetRow,
  passRow,
  themeLayerFilesFor,
  visitHookWriteRules,
} from './internals.js';
 
const ID = 'structural-no-pseudo-state-hook-writes';
const LABEL = 'No hook writes inside pseudo-state rules';
const CATEGORY = 'customer-reach' as const;
 
const PSEUDO_STATE_RE =
  /:(?:focus-visible|focus-within|placeholder-shown|hover|focus|active|disabled|checked|target|visited|empty)\b/g;
const DISABLED_ATTR_RE = /\[disabled\]/g;
const WHITESPACE_RUN_RE = /\s+/g;
 
/**
 * Strip pseudo-state pseudo-classes and `[disabled]` from each selector
 * in the comma list to surface the idle selector(s) the author should
 * hoist the hook write to.
 */
function idleSelectorsFor(rawSelector: string): string {
  const idleParts = rawSelector
    .split(',')
    .map((part) =>
      part
        .replaceAll(PSEUDO_STATE_RE, '')
        .replaceAll(DISABLED_ATTR_RE, '')
        .replaceAll(WHITESPACE_RUN_RE, ' ')
        .trim(),
    )
    .filter((part) => part.length > 0);
  const unique = Array.from(new Set(idleParts));
  return unique.join(', ');
}
 
export const noPseudoStateHookWrites: ComplianceCheck = (input): ComplianceRow => {
  const files = themeLayerFilesFor(input);
  if (!files) return notRunYetRow(ID, LABEL, CATEGORY);
  if (files.length === 0) {
    return passRow(ID, LABEL, 'Component has no theme or theme-base files; nothing to check.', CATEGORY);
  }
 
  const offenders: Offender[] = [];
 
  for (const file of files) {
    visitHookWriteRules(file, ({ rule, hookDecls }) => {
      const cls = classifySelector(rule.selector, input.componentName);
      if (!cls.issues.includes('pseudo-state')) return;
      // Carveout: writes inside a BEM modifier rule (e.g.
      // `.slds-button_neutral:hover`) target a system-owned modifier.
      // Customers customize the base selector, not modifiers; the
      // modifier's per-state write is the system's intended paint.
      if (targetsBemModifier(rule.selector)) return;
      const idle = idleSelectorsFor(rule.selector);
      for (const decl of hookDecls) {
        const fix = idle
          ? `Move \`${decl.prop}\` to \`${idle}\` and read it from this rule via \`var(${decl.prop})\`.`
          : `Move \`${decl.prop}\` out of the pseudo-state rule onto the idle selector and read it from this rule via \`var(${decl.prop})\`.`;
        offenders.push({
          selector: rule.selector,
          hook: decl.prop,
          location: declLocation(file, decl),
          fix,
        });
      }
    });
  }
 
  if (offenders.length === 0) {
    return passRow(
      ID,
      LABEL,
      'No customer hooks are written inside pseudo-state rules. Per-state hooks are defined on the idle selector and read from the state selector via a var() fallback chain.',
      CATEGORY,
    );
  }
 
  return failRow(
    ID,
    LABEL,
    `${offenders.length} per-state hook write${offenders.length === 1 ? '' : 's'} inside a pseudo-state rule. ` +
      `These walls block customer overrides because the in-pseudo-state assignment outranks the customer's idle-selector value. ` +
      `Hoist each write to the idle selector and have the pseudo-state rule read the hook via var().`,
    offenders,
  );
};