storybookjs / storybook

Storybook is a frontend workshop for building UI components and pages in isolation. Made for UI development, testing, and documentation.

Home Page:https://storybook.js.org

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

[Bug]: a11y-addon - APCA Contrast check - Can't pass custom evaluate function to rule

peterlaxalt opened this issue · comments

Describe the bug

I am trying to integrate Stack Exchange's implementation of the APCA contrast check by extending the a11y addon options. For now I've duplicated his script into a separate folder in the project and modified his function to not run axe.configure but instead return the check and rule manually.

Screen Shot 2024-06-19 at 3 29 30 PM

The axe docs are a little wonky, and say that evaluate accepts a string, but if you look into the code, the type does accept string or function.

I've got it working and showing up on Storybook in the a11y addon, but the check always comes back as 'Incomplete.' Are we passing the node differently somehow? I've tried accessing the virtualNode.actualNode as well with no luck. Just always returns 'Incomplete'. I can't even force it to return true or false as well. The APCA contrast check works on it's own with a manual axe setup, but not inside of Storybook.

The updated function is below. Am I missing anything?

In preview.ts

import type { Preview } from "@storybook/react";
import '../src/assets/styles/globals.scss'
import '../public/main.latin.min.css'
import { registerAPCACheck } from '../vendor/apca-check/index.js';

const preview: Preview = {
  parameters: {
    controls: {
      matchers: {
        color: /(background|color)$/i,
        date: /Date$/i,
      },
    },
    backgrounds: {
      default: 'light',
      values: [
        { name: 'light', value: 'rgba(255,255,255,1.00)' },
        { name: 'dark', value: 'rgba(33,47,61,1.00)' },
      ],
    },
    a11y: {
      config: {
        checks: [
          { ... registerAPCACheck('silver-plus').check },
        ],
        rules: [
          { ...registerAPCACheck('silver-plus').rule },
          { id: "color-contrast", enabled: false },
        ],
      },
    }
  },
};


export default preview;

In apca-check.js, just a copy + pase from the npm package. For apca-bronze.js / apca-silver-plus.js, they are copy and pastes as well.

const apcaW3 = require("apca-w3");
const calcAPCA = apcaW3.calcAPCA;
const APCABronzeConformanceThresholdFn = require("./apca-bronze.js");
const APCASilverPlusConformanceThresholdFn = require("./apca-silver-plus.js");
const axe = require("axe-core");

const generateColorContrastAPCAConformanceCheck = (
  conformanceLevel,
  conformanceThresholdFn
) => {
  return {
    id: `color-contrast-apca-${conformanceLevel}-conformance`,
    metadata: {
      impact: "serious",
      messages: {
        pass:
          "Element has sufficient APCA " +
          conformanceLevel +
          "level lightness contrast (Lc) of ${this.data.apcaContrast}Lc (foreground color: ${this.data.fgColor}, background color: ${this.data.bgColor}, font size: ${this.data.fontSize}, font weight: ${this.data.fontWeight}). Expected minimum APCA contrast of ${this.data.apcaThreshold}}",
        fail: {
          default:
            "Element has insufficient APCA " +
            conformanceLevel +
            "level contrast of ${this.data.apcaContrast}Lc (foreground color: ${this.data.fgColor}, background color: ${this.data.bgColor}, font size: ${this.data.fontSize}, font weight: ${this.data.fontWeight}). Expected minimum APCA lightness contrast of ${this.data.apcaThreshold}Lc",
          increaseFont:
            "Element has insufficient APCA " +
            conformanceLevel +
            " level contrast of ${this.data.apcaContrast}Lc (foreground color: ${this.data.fgColor}, background color: ${this.data.bgColor}, font size: ${this.data.fontSize}, font weight: ${this.data.fontWeight}). Increase font size and/or font weight to meet APCA conformance minimums",
        },
        incomplete: "Unable to determine APCA lightness contrast (Lc)",
      },
    },
    evaluate(node) {      
      const nodeStyle = window.getComputedStyle(node);
      const fontSize = nodeStyle.getPropertyValue("font-size");
      const fontWeight = nodeStyle.getPropertyValue("font-weight");
      const bgColor = axe.commons.color.getBackgroundColor(node);
      const fgColor = axe.commons.color.getForegroundColor(
        node,
        false,
        bgColor
      );
      // missing data to determine APCA contrast for this node
      if (!bgColor || !fgColor || !fontSize || !fontWeight) {
        return undefined;
      }
      const toRGBA = (color) => {
        return `rgba(${color.red}, ${color.green}, ${color.blue}, ${color.alpha})`;
      };
      const apcaContrast = Math.abs(calcAPCA(toRGBA(fgColor), toRGBA(bgColor)));
      const apcaThreshold = conformanceThresholdFn(fontSize, fontWeight);
      this.data = {
        fgColor: fgColor.toHexString(),
        bgColor: bgColor.toHexString(),
        fontSize: `${((parseFloat(fontSize) * 72) / 96).toFixed(1)}pt (${parseFloat(fontSize)}px)`,
        fontWeight: fontWeight,
        apcaContrast: Math.round(apcaContrast * 100) / 100,
        apcaThreshold: apcaThreshold,
        messageKey: apcaThreshold === null ? "increaseFont" : "default",
      };

      return apcaThreshold ? apcaContrast >= apcaThreshold : void 0;
    },
  };
};

const generateColorContrastAPCARule = (conformanceLevel) => ({
  id: `color-contrast-apca-${conformanceLevel}`,
  impact: "serious",
  matches: "color-contrast-matches",
  metadata: {
    help: "Elements must meet APCA conformance minimums thresholds",
    description: `Ensures the contrast between foreground and background colors meets APCA ${conformanceLevel} level conformance minimums thresholds`,
    helpUrl:
      "https://readtech.org/ARC/tests/visual-readability-contrast/?tn=criterion",
  },
  all: [`color-contrast-apca-${conformanceLevel}-conformance`],
  tags: ["apca", "wcag3", `apca-${conformanceLevel}`],
});

const registerAPCACheck = (conformanceLevel, customConformanceThresholdFn) => {
  if (
    conformanceLevel === "custom" &&
    typeof customConformanceThresholdFn !== "function"
  ) {
    throw new Error(
      "A custom conformance level requires a custom conformance threshold function"
    );
  }
  const conformanceThresholdFnMap = {
    bronze: APCABronzeConformanceThresholdFn,
    silver: APCASilverPlusConformanceThresholdFn,
    custom: customConformanceThresholdFn,
  };
  return {
    rule: generateColorContrastAPCARule(conformanceLevel),
    check: generateColorContrastAPCAConformanceCheck(
      conformanceLevel,
      conformanceThresholdFnMap[conformanceLevel]
    ),
  };
};

module.exports = { registerAPCACheck };

Reproduction link

https://github.com/StackExchange/apca-check

Reproduction steps

No response

System

Storybook Environment Info:
(node:81977) [DEP0040] DeprecationWarning: The `punycode` module is deprecated. Please use a userland alternative instead.
(Use `node --trace-deprecation ...` to show where the warning was created)

  System:
    OS: macOS 12.0.1
    CPU: (8) x64 Apple M1
    Shell: 5.8 - /bin/zsh
  Binaries:
    Node: 21.0.0 - ~/.nvm/versions/node/v21.0.0/bin/node
    Yarn: 1.22.22 - ~/.nvm/versions/node/v21.0.0/bin/yarn
    npm: 10.2.0 - ~/.nvm/versions/node/v21.0.0/bin/npm <----- active
    pnpm: 9.4.0 - ~/.nvm/versions/node/v21.0.0/bin/pnpm
  Browsers:
    Chrome: 126.0.6478.62
    Safari: 15.1
  npmPackages:
    @storybook/addon-a11y: ^8.1.10 => 8.1.10
    @storybook/addon-backgrounds: ^8.1.9 => 8.1.9
    @storybook/addon-console: ^3.0.0 => 3.0.0
    @storybook/addon-essentials: ^8.1.9 => 8.1.9
    @storybook/addon-interactions: ^8.1.9 => 8.1.9
    @storybook/addon-links: ^8.1.9 => 8.1.9
    @storybook/addon-onboarding: ^8.1.9 => 8.1.9
    @storybook/addon-styling-webpack: ^1.0.0 => 1.0.0
    @storybook/blocks: ^8.1.9 => 8.1.9
    @storybook/nextjs: ^8.1.9 => 8.1.9
    @storybook/react: ^8.1.9 => 8.1.9
    @storybook/test: ^8.1.9 => 8.1.9
    eslint-plugin-storybook: ^0.8.0 => 0.8.0
    storybook: ^8.1.9 => 8.1.9

Additional context

No response