Skip to content

Commit

Permalink
add contrasting color node (#30)
Browse files Browse the repository at this point in the history
* [TASK] add contrasting color node

* [TASK] update test case, change wcag

* [TASK] update test cases to use WcagVersion

* [TASK] add changeset

---------

Co-authored-by: SorsOps <[email protected]>
  • Loading branch information
mck and SorsOps authored Jul 7, 2023
1 parent b1a09fd commit be38194
Show file tree
Hide file tree
Showing 10 changed files with 297 additions and 8 deletions.
6 changes: 6 additions & 0 deletions .changeset/quick-oranges-fail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@tokens-studio/graph-engine": minor
"@tokens-studio/graph-engine-ui": patch
---

Add new node for contrasting color supporting wcag 2.1 and 3.0
1 change: 1 addition & 0 deletions packages/documentation/pages/nodes/color-nodes/_meta.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"contrasting": "Get Contrasting Color",
"generate-scale": "Generate scale",
"create-color": "Create color",
"blend": "Blend",
Expand Down
87 changes: 87 additions & 0 deletions packages/graph-engine/src/nodes/color/contrasting.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/**
* Performs a contrast calculation between two colors using APCA-W3 calcs
*
* @packageDocumentation
*/

import { NodeDefinition, NodeTypes } from "#/types.js";
import { calcAPCA } from "apca-w3";
import chroma from "chroma-js";

export const type = NodeTypes.CONTRASTING;

export type State = {
a: string,
b: string,
background: string,
wcag: WcagVersion,
threshold: number,
contrast: number
}

type contrastingValues = {
color: string,
sufficient: boolean,
contrast: number
}

export enum WcagVersion {
V2 = "2.1",
V3 = "3.0"
}

export const defaults: State = {
a: "#000000",
b: "#ffffff",
background: "#ffffff",
wcag: WcagVersion.V3,
threshold: 60,
contrast: 0
}

export const process = (input, state: State): contrastingValues => {
const final = {
...state,
...input,
};

let a,b;

if (final.wcag == WcagVersion.V2) {
a = chroma.contrast(final.a, final.background);
b = chroma.contrast(final.b, final.background);

} else {
a = Math.abs(calcAPCA(final.a, final.background));
b = Math.abs(calcAPCA(final.b, final.background));
}

let contrast: contrastingValues;

if ( a > b ) {
contrast = {
color: final.a,
sufficient: a >= final.threshold,
contrast: a
};
} else {
contrast = {
color: final.b,
sufficient: b >= final.threshold,
contrast: b
};
}

return contrast;
};

export const mapOutput = (input, state, processed: contrastingValues) => {
return processed;
};

export const node: NodeDefinition<State, State> = {
defaults,
type,
process,
mapOutput,
};
12 changes: 11 additions & 1 deletion packages/graph-engine/src/nodes/color/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
import { node as advancedBlend } from "./advancedBlend.js";
import { node as blend } from "./blend.js";
import { node as contrasting } from "./contrasting.js";
import { node as create } from "./create.js";
import { node as extract } from "./extract.js";
import { node as scale } from "./scale.js";
import { node as convert } from "./convert.js";
import { node as wheel } from "./wheel.js";

export const nodes = [blend, scale, create, advancedBlend, extract, convert, wheel];
export const nodes = [
blend,
scale,
contrasting,
create,
advancedBlend,
extract,
convert,
wheel,
];
2 changes: 2 additions & 0 deletions packages/graph-engine/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ export enum NodeTypes {
POW = "studio.tokens.math.pow",

// Color
CONTRASTING = "studio.tokens.color.contrasting",
SCALE = "studio.tokens.color.scale",
CONVERT_COLOR = "studio.tokens.color.convert",
BLEND = "studio.tokens.color.blend",
Expand Down Expand Up @@ -137,6 +138,7 @@ export enum NodeTypes {
LOWER = "studio.tokens.string.lowercase",
REGEX = "studio.tokens.string.regex",
PASS_UNIT = "studio.tokens.typing.passUnit",

//Accessibility
CONTRAST = "studio.tokens.accessibility.contrast",
COLOR_BLINDNESS = "studio.tokens.accessibility.colorBlindness",
Expand Down
67 changes: 67 additions & 0 deletions packages/graph-engine/tests/suites/nodes/color/contrasting.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { node, WcagVersion } from "#/nodes/color/contrasting.js";
import { executeNode } from "#/core.js";

describe("color/blend", () => {
it("should return the more contrasting color correctly with WCAG 3", async () => {
const output = await executeNode({
input: {
a: "#000000",
b: "#ffffff",
background: "#ffffff",
wcag: WcagVersion.V3,
threshold: 60
},
node,
state: node.defaults,
nodeId: "",
});

expect(output).toEqual({
color: "#000000",
sufficient: true, // assuming contrast value is above 60
contrast: 106.04067321268862
});
});

it("should return the more contrasting color correctly with WCAG 2", async () => {
const output = await executeNode({
input: {
a: "#000000",
b: "#ffffff",
background: "#000000",
wcag: WcagVersion.V2,
threshold: 4.5
},
node,
state: node.defaults,
nodeId: "",
});

expect(output).toEqual({
color: "#ffffff",
sufficient: true, // assuming contrast value is above 4.5
contrast: 21
});
});

it("should return false for sufficient contrast if below threshold", async () => {
const output = await executeNode({
input: {
a: "#dddddd",
b: "#bbbbbb",
background: "#ffffff",
wcag: WcagVersion.V3,
threshold: 60
},
node,
state: node.defaults,
nodeId: "",
});

expect(output).toEqual({
color: "#bbbbbb",
sufficient: false, // assuming contrast value is below 60
contrast: 36.717456545363994
});
});
});
5 changes: 5 additions & 0 deletions packages/ui/src/components/flow/dropPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,11 @@ const types = {
},
],
color: [
{
type: NodeTypes.CONTRASTING,
icon: <Half2Icon />,
text: 'Contrasting Color',
},
{
type: NodeTypes.SCALE,
icon: '...',
Expand Down
108 changes: 108 additions & 0 deletions packages/ui/src/components/flow/nodes/color/contrastingNode.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { Button, DropdownMenu, Label, Stack, Text, TextInput } from '@tokens-studio/ui';
import { Handle, HandleContainer } from '#/components/flow/handles.tsx';
import { PreviewColor } from '../../preview/color.tsx';
import { WrapNode, useNode } from '../../wrapper/nodeV2.tsx';
import { node, WcagVersion } from '@tokens-studio/graph-engine/nodes/color/contrasting.js';
import PreviewNumber from '../../preview/number.tsx';
import React, {useCallback} from 'react';
import {PreviewBoolean} from "../../preview/boolean.tsx";
import {PreviewAny} from "../../preview/any.tsx";

const ContrastingNode = () => {
const { input, output, state, setState } = useNode();
const setWcag = useCallback((ev) => {
const version = WcagVersion[ev.currentTarget.dataset.key as keyof typeof WcagVersion];
setState((state) => ({
...state,
wcag: version,
}));
}, [setState]);

const setThreshold = useCallback((ev) => {
const threshold = ev.target.value;
setState((state) => ({
...state,
threshold,
}));
}, [setState]);

return (
<Stack direction="row" gap={4}>
<HandleContainer type="target">
<Handle id="a">
<Text>Color A</Text>
<Text>
<PreviewColor value={input.a} />
</Text>
</Handle>
<Handle id="b">
<Text>Color B</Text>
<Text>
<PreviewColor value={input.b} />
</Text>
</Handle>
<Handle id="background">
<Text>Background</Text>
<Text>
<PreviewColor value={input.background} />
</Text>
</Handle>
<Handle id="wcag">
<Label>WCAG Version</Label>

{input.wcag !== undefined ? (
<Text>{input.wcag}</Text>
) : (
<DropdownMenu>
<DropdownMenu.Trigger asChild>
<Button variant="secondary" asDropdown size="small">
{state.wcag}
</Button>
</DropdownMenu.Trigger>

<DropdownMenu.Portal>
<DropdownMenu.Content>
{Object.entries(WcagVersion).map(([key, value]) => (
<DropdownMenu.Item key={key} onClick={setWcag} data-key={key}>
{value}
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
)}
</Handle>
<Handle id="threshold">
<Stack direction="row" justify="between" gap={3} align="center">
<Label>Threshold</Label>
{input.threshold !== undefined ? (
<PreviewNumber value={input.threshold} />
) : (
<TextInput onChange={setThreshold} value={state.threshold} />
)}
</Stack>
</Handle>
</HandleContainer>

<HandleContainer type="source">
<Handle id="color">
<Text>Color</Text>
<PreviewColor value={output?.color} />
</Handle>
<Handle id="contrast">
<Text>Contrast</Text>
<PreviewAny value={output?.contrast} />
</Handle>
<Handle id="sufficient">
<Text>Sufficient</Text>
<PreviewBoolean value={output?.sufficient} />
</Handle>
</HandleContainer>
</Stack>
);
};

export default WrapNode(ContrastingNode, {
...node,
title: 'Contrast',
});
15 changes: 8 additions & 7 deletions packages/ui/src/components/flow/nodes/color/wheelNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,14 @@ const WheelNode = () => {
let label;
try {
label = chroma(value).css('hsl');
}
catch (err) {
} catch (err) {
label = 'invalid';
}
return (
<Handle id={key} key={key}>
<Label>{key}</Label>
<Tooltip label={label}>
<PreviewColor value={value} />
<PreviewColor value={value} />
</Tooltip>
</Handle>
);
Expand All @@ -53,7 +52,11 @@ const WheelNode = () => {
{input.hueAmount ? (
<PreviewNumber value={input.hueAmount} />
) : (
<TextInput data-key="hueAmount" value={state.hueAmount} onChange={onChange} />
<TextInput
data-key="hueAmount"
value={state.hueAmount}
onChange={onChange}
/>
)}
</Handle>
<Handle id="hueAngle">
Expand Down Expand Up @@ -113,9 +116,7 @@ const WheelNode = () => {
</LabelNoWrap>
</Stack>
</Handle>
<Tooltip.Provider>
{outputHandles}
</Tooltip.Provider>
<Tooltip.Provider>{outputHandles}</Tooltip.Provider>
</HandleContainer>
</Stack>
);
Expand Down
2 changes: 2 additions & 0 deletions packages/ui/src/components/flow/nodes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import ColorBlindness from './accessibility/ColorBlindNessNode.tsx';
import CompareNode from './logic/compare.tsx';
import ConstantNode from './input/constantNode.tsx';
import ContrastNode from './accessibility/contrastNode.tsx';
import ContrastingNode from './color/contrastingNode.tsx';
import CosNode from './math/cosNode.tsx';
import CountNode from './math/countNode.tsx';
import CreateColorNode from './color/createColorNode.tsx';
Expand Down Expand Up @@ -109,6 +110,7 @@ export const { nodeTypes, stateInitializer } = processTypes([
ResolveAliasesNode,
ArrifyNode,
ContrastNode,
ContrastingNode,
SquashNode,
blendNode,
modNode,
Expand Down

0 comments on commit be38194

Please sign in to comment.