= props => {
10 | const { textColor, linkColor, textDecoration } = props;
11 |
12 | return (
13 |
14 | Removing underlines from links in HTML text presents an accessibility
15 | challenge. In order for a design to be{" "}
16 |
23 | considered accessible
24 |
25 | , there is now a three-sided design contraint - or what I call "The
26 | Contrast Triangle". Your text, links and background colors must now{" "}
27 | all have sufficient contrast from each other.{" "}
28 |
35 | Links must have a contrast ratio of 3:1 from their surrounding text.
36 | {" "}
37 | This is so that colorblind users can tell what pieces of text are links.
38 | By not using underlines, a design has to rely on contrast alone to achieve
39 | this. Even the default blue link color in browsers doesn't meet this
40 | contrast level.{" "}
41 |
48 | Both the text and links have to have a contrast ratio of 4.5:1, or 3:1
49 | if it's large scale text.
50 |
51 |
52 | );
53 | };
54 |
55 | export default PreviewParagraph;
56 |
--------------------------------------------------------------------------------
/src/utils/type-of-color.tsx:
--------------------------------------------------------------------------------
1 | import { namedColors } from "./named-colors";
2 |
3 | // taking the named colors and converting them to lowercase
4 | // for comparison
5 | const lowerCaseNamedColors = namedColors.map(c => c.toLowerCase());
6 |
7 | const typeOfColor = (color: string): string => {
8 | switch (true) {
9 | // https://stackoverflow.com/a/8027444/1173898
10 | case /^(#)?[0-9A-F]{3}$/i.test(color):
11 | return "hex3";
12 |
13 | case /^(#)?[0-9A-F]{6}$/i.test(color):
14 | return "hex6";
15 |
16 | case /^(#)?[0-9A-F]{4}$/i.test(color):
17 | return "hex4";
18 |
19 | case /^(#)?[0-9A-F]{8}$/i.test(color):
20 | return "hex8";
21 |
22 | case color.indexOf("rgba") === 0 && color.indexOf(")") !== -1:
23 | return "rgba";
24 |
25 | case color.indexOf("rgb") === 0 && color.indexOf(")") !== -1:
26 | return "rgb";
27 |
28 | case color.indexOf("hsla") === 0 && color.indexOf(")") !== -1:
29 | return "hsla";
30 |
31 | case color.indexOf("hsl") === 0 && color.indexOf(")") !== -1:
32 | return "hsl";
33 |
34 | // converting user input to lowercase so the input
35 | // can be "rebeccapurple" or "RebeccaPurple"
36 | case lowerCaseNamedColors.includes(color.toLowerCase()):
37 | return "named";
38 |
39 | default:
40 | return "none";
41 | }
42 | };
43 |
44 | const isValidColor = (color: string): boolean => {
45 | switch (true) {
46 | // https://stackoverflow.com/a/8027444/1173898
47 | case /^(#)?[0-9A-F]{3}$/i.test(color):
48 | case /^(#)?[0-9A-F]{6}$/i.test(color):
49 | case /^(#)?[0-9A-F]{4}$/i.test(color):
50 | case /^(#)?[0-9A-F]{8}$/i.test(color):
51 | case color.indexOf("rgba") === 0 && color.indexOf(")") !== -1:
52 | case color.indexOf("rgb") === 0 && color.indexOf(")") !== -1:
53 | case color.indexOf("hsla") === 0 && color.indexOf(")") !== -1:
54 | case color.indexOf("hsl") === 0 && color.indexOf(")") !== -1:
55 | case lowerCaseNamedColors.includes(color.toLowerCase()):
56 | return true;
57 | default:
58 | return false;
59 | }
60 | };
61 |
62 | export { typeOfColor, isValidColor };
63 |
--------------------------------------------------------------------------------
/src/utils/to-hex.test.ts:
--------------------------------------------------------------------------------
1 | import { hexToHex, hslToHex, rgbToHex, toHex } from "./to-hex";
2 |
3 | describe("Hex To Hex conversion", () => {
4 | it("correct hex for black", () => {
5 | expect(hexToHex("#000")).toEqual("#000000");
6 | expect(hexToHex("000")).toEqual("#000000");
7 | expect(hexToHex("#000000")).toEqual("#000000");
8 | expect(hexToHex("000000")).toEqual("#000000");
9 | });
10 |
11 | it("correct hex for white", () => {
12 | expect(hexToHex("#fff")).toEqual("#ffffff");
13 | expect(hexToHex("fff")).toEqual("#ffffff");
14 | expect(hexToHex("#ffffff")).toEqual("#ffffff");
15 | expect(hexToHex("ffffff")).toEqual("#ffffff");
16 | });
17 | });
18 |
19 | describe("HSL to Hex conversion", () => {
20 | it("correct Hex for black", () => {
21 | expect(hslToHex("hsl(0, 0%, 0%)")).toBe("#000000");
22 | });
23 |
24 | it("correct Hex for white", () => {
25 | expect(hslToHex("hsl(0, 0%, 100%)")).toBe("#ffffff");
26 | });
27 |
28 | it("correct Hex for orange", () => {
29 | expect(hslToHex("hsl(30, 100%, 50%)")).toEqual("#ff8000");
30 | })
31 | });
32 |
33 | describe("RGB to Hex conversion", () => {
34 | it("correct Hex for black", () => {
35 | expect(rgbToHex("rgb(0, 0, 0)")).toBe("#000000");
36 | });
37 |
38 | it("correct Hex for white", () => {
39 | expect(rgbToHex("rgb(255, 255, 255)")).toBe("#ffffff");
40 | });
41 | });
42 |
43 | // integration
44 | describe("To Hex conversion", () => {
45 | it("correct hex for black", () => {
46 | expect(toHex("hsl(0, 0%, 0%)")).toEqual("#000000");
47 | expect(toHex("rgb(0, 0, 0)")).toEqual("#000000");
48 | expect(toHex("#000")).toEqual("#000000");
49 | expect(toHex("000")).toEqual("#000000");
50 | expect(toHex("#000000")).toEqual("#000000");
51 | expect(toHex("000000")).toEqual("#000000");
52 | });
53 |
54 | it("correct hex for white", () => {
55 | expect(toHex("hsl(0, 0%, 100%)")).toEqual("#ffffff");
56 | expect(toHex("rgb(255, 255, 255)")).toEqual("#ffffff");
57 | expect(toHex("#fff")).toEqual("#ffffff");
58 | expect(toHex("fff")).toEqual("#ffffff");
59 | expect(toHex("#ffffff")).toEqual("#ffffff");
60 | expect(toHex("ffffff")).toEqual("#ffffff");
61 | });
62 |
63 | it("correct hex for orange", () => {
64 | expect(toHex("hsl(30, 100%, 50%)")).toEqual("#ff8000");
65 | })
66 | });
67 |
--------------------------------------------------------------------------------
/src/utils/type-of-color.test.ts:
--------------------------------------------------------------------------------
1 | import { typeOfColor, isValidColor } from "./type-of-color";
2 |
3 | describe("Type Of Color", () => {
4 | it("returns none for a bad value", () => {
5 | expect(typeOfColor("foo")).toBe("none");
6 | expect(typeOfColor("#foo")).toBe("none");
7 | expect(typeOfColor("#fooo")).toBe("none");
8 | expect(typeOfColor("foobar")).toBe("none");
9 | expect(typeOfColor("#foobar")).toBe("none");
10 | });
11 |
12 | it("returns named for a named color", () => {
13 | expect(typeOfColor("RebeccaPurple")).toBe("named");
14 | expect(typeOfColor("rebeccapurple")).toBe("named");
15 | expect(typeOfColor("REBECCAPURPLE")).toBe("named");
16 | });
17 |
18 | it("returns hex6", () => {
19 | expect(typeOfColor("#ffffff")).toBe("hex6");
20 | expect(typeOfColor("ffffff")).toBe("hex6");
21 | });
22 |
23 | it("returns hex3", () => {
24 | expect(typeOfColor("#fff")).toBe("hex3");
25 | expect(typeOfColor("fff")).toBe("hex3");
26 | });
27 |
28 | it("returns hex4", () => {
29 | expect(typeOfColor("#fff0")).toBe("hex4");
30 | expect(typeOfColor("fff0")).toBe("hex4");
31 | });
32 |
33 | it("returns hex8", () => {
34 | expect(typeOfColor("#ffffff0A")).toBe("hex8");
35 | expect(typeOfColor("ffffff0A")).toBe("hex8");
36 | });
37 |
38 | it("returns rgba", () => {
39 | expect(typeOfColor("rgba(255, 255, 255, 1)")).toBe("rgba");
40 | });
41 |
42 | it("returns rgb", () => {
43 | expect(typeOfColor("rgb(255, 255, 255)")).toBe("rgb");
44 | });
45 |
46 | it("returns hsla", () => {
47 | expect(typeOfColor("hsla(0, 0%, 100, 1)")).toBe("hsla");
48 | });
49 |
50 | it("returns hsl", () => {
51 | expect(typeOfColor("hsl(0, 0%, 100)")).toBe("hsl");
52 | });
53 | });
54 |
55 | describe("Is Valid Color", () => {
56 | it("returns false for a bad value", () => {
57 | expect(isValidColor("foo")).toBe(false);
58 | expect(isValidColor("#foo")).toBe(false);
59 | expect(isValidColor("#fooo")).toBe(false);
60 | expect(isValidColor("#foobar")).toBe(false);
61 | });
62 |
63 | it("returns true for a good value", () => {
64 | expect(isValidColor("fff")).toBe(true);
65 | expect(isValidColor("#fff")).toBe(true);
66 | expect(isValidColor("#ffff")).toBe(true);
67 | expect(isValidColor("rgb(255, 255, 255)")).toBe(true);
68 | expect(isValidColor("hsla(0, 0%, 100, 1)")).toBe(true);
69 | });
70 |
71 |
72 | });
73 |
--------------------------------------------------------------------------------
/src/logo.svg:
--------------------------------------------------------------------------------
1 |
8 |
--------------------------------------------------------------------------------
/src/utils/named-colors.tsx:
--------------------------------------------------------------------------------
1 | // taken from this github gist comment
2 | // https://gist.github.com/bobspace/2712980#gistcomment-2688195
3 | const namedColors = [
4 | "AliceBlue",
5 | "AntiqueWhite",
6 | "Aqua",
7 | "Aquamarine",
8 | "Azure",
9 | "Beige",
10 | "Bisque",
11 | "Black",
12 | "BlanchedAlmond",
13 | "Blue",
14 | "BlueViolet",
15 | "Brown",
16 | "BurlyWood",
17 | "CadetBlue",
18 | "Chartreuse",
19 | "Chocolate",
20 | "Coral",
21 | "CornflowerBlue",
22 | "Cornsilk",
23 | "Crimson",
24 | "Cyan",
25 | "DarkBlue",
26 | "DarkCyan",
27 | "DarkGoldenRod",
28 | "DarkGray",
29 | "DarkGrey",
30 | "DarkGreen",
31 | "DarkKhaki",
32 | "DarkMagenta",
33 | "DarkOliveGreen",
34 | "DarkOrange",
35 | "DarkOrchid",
36 | "DarkRed",
37 | "DarkSalmon",
38 | "DarkSeaGreen",
39 | "DarkSlateBlue",
40 | "DarkSlateGray",
41 | "DarkSlateGrey",
42 | "DarkTurquoise",
43 | "DarkViolet",
44 | "DeepPink",
45 | "DeepSkyBlue",
46 | "DimGray",
47 | "DimGrey",
48 | "DodgerBlue",
49 | "FireBrick",
50 | "FloralWhite",
51 | "ForestGreen",
52 | "Fuchsia",
53 | "Gainsboro",
54 | "GhostWhite",
55 | "Gold",
56 | "GoldenRod",
57 | "Gray",
58 | "Grey",
59 | "Green",
60 | "GreenYellow",
61 | "HoneyDew",
62 | "HotPink",
63 | "IndianRed",
64 | "Indigo",
65 | "Ivory",
66 | "Khaki",
67 | "Lavender",
68 | "LavenderBlush",
69 | "LawnGreen",
70 | "LemonChiffon",
71 | "LightBlue",
72 | "LightCoral",
73 | "LightCyan",
74 | "LightGoldenRodYellow",
75 | "LightGray",
76 | "LightGrey",
77 | "LightGreen",
78 | "LightPink",
79 | "LightSalmon",
80 | "LightSeaGreen",
81 | "LightSkyBlue",
82 | "LightSlateGray",
83 | "LightSlateGrey",
84 | "LightSteelBlue",
85 | "LightYellow",
86 | "Lime",
87 | "LimeGreen",
88 | "Linen",
89 | "Magenta",
90 | "Maroon",
91 | "MediumAquaMarine",
92 | "MediumBlue",
93 | "MediumOrchid",
94 | "MediumPurple",
95 | "MediumSeaGreen",
96 | "MediumSlateBlue",
97 | "MediumSpringGreen",
98 | "MediumTurquoise",
99 | "MediumVioletRed",
100 | "MidnightBlue",
101 | "MintCream",
102 | "MistyRose",
103 | "Moccasin",
104 | "NavajoWhite",
105 | "Navy",
106 | "OldLace",
107 | "Olive",
108 | "OliveDrab",
109 | "Orange",
110 | "OrangeRed",
111 | "Orchid",
112 | "PaleGoldenRod",
113 | "PaleGreen",
114 | "PaleTurquoise",
115 | "PaleVioletRed",
116 | "PapayaWhip",
117 | "PeachPuff",
118 | "Peru",
119 | "Pink",
120 | "Plum",
121 | "PowderBlue",
122 | "Purple",
123 | "RebeccaPurple",
124 | "Red",
125 | "RosyBrown",
126 | "RoyalBlue",
127 | "SaddleBrown",
128 | "Salmon",
129 | "SandyBrown",
130 | "SeaGreen",
131 | "SeaShell",
132 | "Sienna",
133 | "Silver",
134 | "SkyBlue",
135 | "SlateBlue",
136 | "SlateGray",
137 | "SlateGrey",
138 | "Snow",
139 | "SpringGreen",
140 | "SteelBlue",
141 | "Tan",
142 | "Teal",
143 | "Thistle",
144 | "Tomato",
145 | "Turquoise",
146 | "Violet",
147 | "Wheat",
148 | "White",
149 | "WhiteSmoke",
150 | "Yellow",
151 | "YellowGreen"
152 | ];
153 |
154 | export { namedColors };
155 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 |
3 | [](https://app.netlify.com/sites/contrast-triangle/deploys)
4 |
5 | # The Contrast Triangle
6 |
7 | https://contrast-triangle.com
8 |
9 | _______________
10 |
11 | ## Prior art
12 |
13 | Inspired by [Lea Verou's](https://lea.verou.me/) excellent [Contrast Ratio](https://contrast-ratio.com/)
14 |
15 | Most of the color translation logic is from this CSS tricks article by [Jon Kanter](https://jonkantner.com/):
16 |
17 | https://css-tricks.com/converting-color-spaces-in-javascript/
18 |
19 | Query Parameter support is from this Medium article by [Fernando Abolafio](https://github.com/fernandoabolafio)
20 |
21 | https://medium.com/swlh/using-react-hooks-to-sync-your-component-state-with-the-url-query-string-81ccdfcb174f
22 |
23 | Luminance calculation function is from this gist by [John Schulz](https://gist.github.com/jfsiii):
24 |
25 | https://gist.github.com/jfsiii/5641126
26 |
27 | On / Off Toggle inspired by the [Toggle Button Inclusive Component Pattern](https://inclusive-components.design/toggle-button/) by [Heydon Pickering](https://heydonworks.com/)
28 |
29 | _______________
30 |
31 | This project was made with [Create React App](https://github.com/facebook/create-react-app).
32 |
33 | ## Available Scripts
34 |
35 | In the project directory, you can run:
36 |
37 | ### `yarn start`
38 |
39 | Runs the app in the development mode.
40 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
41 |
42 | The page will reload if you make edits.
43 | You will also see any lint errors in the console.
44 |
45 | ### `yarn test`
46 | ### `yarn test --watchAll`
47 |
48 | Launches the test runner in the interactive watch mode.
49 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
50 |
51 | ### `yarn build`
52 |
53 | Builds the app for production to the `build` folder.
54 | It correctly bundles React in production mode and optimizes the build for the best performance.
55 |
56 | The build is minified and the filenames include the hashes.
57 | Your app is ready to be deployed!
58 |
59 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
60 |
61 | ### `yarn eject`
62 |
63 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!**
64 |
65 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
66 |
67 | Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
68 |
69 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
70 |
71 | ## Learn More
72 |
73 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
74 |
75 | To learn React, check out the [React documentation](https://reactjs.org/).
76 |
--------------------------------------------------------------------------------
/src/utils/to-rgb.test.ts:
--------------------------------------------------------------------------------
1 | import { hexToRgb, hslToRgb, rgbToRgb, toRgb } from "./to-rgb";
2 |
3 | describe("Hex to RGB conversion", () => {
4 | it("correct rgb for black", () => {
5 | expect(hexToRgb("#000000")[0]).toBe(0);
6 | expect(hexToRgb("#000000")[1]).toBe(0);
7 | expect(hexToRgb("#000000")[2]).toBe(0);
8 |
9 | expect(hexToRgb("000000")[0]).toBe(0);
10 | expect(hexToRgb("000000")[1]).toBe(0);
11 | expect(hexToRgb("000000")[2]).toBe(0);
12 |
13 | expect(hexToRgb("#000")[0]).toBe(0);
14 | expect(hexToRgb("#000")[1]).toBe(0);
15 | expect(hexToRgb("#000")[2]).toBe(0);
16 |
17 | expect(hexToRgb("000")[0]).toBe(0);
18 | expect(hexToRgb("000")[1]).toBe(0);
19 | expect(hexToRgb("000")[2]).toBe(0);
20 | });
21 |
22 | it("correct rgb for white", () => {
23 | expect(hexToRgb("#ffffff")[0]).toBe(255);
24 | expect(hexToRgb("#ffffff")[1]).toBe(255);
25 | expect(hexToRgb("#ffffff")[2]).toBe(255);
26 |
27 | expect(hexToRgb("ffffff")[0]).toBe(255);
28 | expect(hexToRgb("ffffff")[1]).toBe(255);
29 | expect(hexToRgb("ffffff")[2]).toBe(255);
30 |
31 | expect(hexToRgb("#fff")[0]).toBe(255);
32 | expect(hexToRgb("#fff")[1]).toBe(255);
33 | expect(hexToRgb("#fff")[2]).toBe(255);
34 |
35 | expect(hexToRgb("fff")[0]).toBe(255);
36 | expect(hexToRgb("fff")[1]).toBe(255);
37 | expect(hexToRgb("fff")[2]).toBe(255);
38 | });
39 |
40 | it("correct rgb for hotpink", () => {
41 | expect(hexToRgb("#ff69b4")[0]).toBe(255);
42 | expect(hexToRgb("#ff69b4")[1]).toBe(105);
43 | expect(hexToRgb("#ff69b4")[2]).toBe(180);
44 | });
45 | });
46 |
47 | describe("HSL to RGB conversion", () => {
48 | it("correct rgb for black", () => {
49 | expect(hslToRgb("hsl(0, 0%, 0%)")[0]).toBe(0);
50 | expect(hslToRgb("hsl(0, 0%, 0%)")[1]).toBe(0);
51 | expect(hslToRgb("hsl(0, 0%, 0%)")[2]).toBe(0);
52 | });
53 |
54 | it("correct rgb for white", () => {
55 | expect(hslToRgb("hsl(0, 0%, 100%)")[0]).toBe(255);
56 | expect(hslToRgb("hsl(0, 0%, 100%)")[1]).toBe(255);
57 | expect(hslToRgb("hsl(0, 0%, 100%)")[2]).toBe(255);
58 | });
59 | });
60 |
61 | describe("rgb to RGB conversion", () => {
62 | it("correct rgb for black", () => {
63 | expect(rgbToRgb("rgb(0, 0, 0)")[0]).toBe(0);
64 | expect(rgbToRgb("rgb(0, 0, 0)")[1]).toBe(0);
65 | expect(rgbToRgb("rgb(0, 0, 0)")[2]).toBe(0);
66 | });
67 |
68 | it("correct rgb for white", () => {
69 | expect(rgbToRgb("rgb(255, 255, 255)")[0]).toBe(255);
70 | expect(rgbToRgb("rgb(255, 255, 255)")[1]).toBe(255);
71 | expect(rgbToRgb("rgb(255, 255, 255)")[2]).toBe(255);
72 | });
73 | });
74 |
75 | // @todo add enzyme or react testing library
76 | // so that styles can be calcuated
77 | // describe("named to RGB conversion", () => {
78 | // it("correct rgb for black", () => {
79 | // expect(namedToRgb("black")).toEqual([0, 0, 0]);
80 | // });
81 |
82 | // it("correct rgb for white", () => {
83 | // expect(namedToRgb("white")).toEqual([255, 255, 255]);
84 | // });
85 | // });
86 |
87 | // integration
88 | describe("To RGB conversion", () => {
89 | it("correct rgb for black", () => {
90 | expect(toRgb("hsl(0, 0%, 0%)")).toEqual([0, 0, 0]);
91 | expect(toRgb("rgb(0, 0, 0)")).toEqual([0, 0, 0]);
92 | expect(toRgb("#000")).toEqual([0, 0, 0]);
93 | expect(toRgb("000")).toEqual([0, 0, 0]);
94 | expect(toRgb("#000000")).toEqual([0, 0, 0]);
95 | expect(toRgb("000000")).toEqual([0, 0, 0]);
96 | // expect(toRgb("black")).toEqual([0, 0, 0]);
97 | });
98 |
99 | it("correct rgb for white", () => {
100 | expect(toRgb("hsl(0, 0%, 100%)")).toEqual([255, 255, 255]);
101 | expect(toRgb("rgb(255, 255, 255)")).toEqual([255, 255, 255]);
102 | expect(toRgb("#fff")).toEqual([255, 255, 255]);
103 | expect(toRgb("fff")).toEqual([255, 255, 255]);
104 | expect(toRgb("#ffffff")).toEqual([255, 255, 255]);
105 | expect(toRgb("ffffff")).toEqual([255, 255, 255]);
106 | });
107 | });
108 |
--------------------------------------------------------------------------------
/src/utils/to-rgba.tsx:
--------------------------------------------------------------------------------
1 | import { typeOfColor } from "./type-of-color";
2 |
3 | // handles #000 or #000000
4 | // based on this function: https://css-tricks.com/converting-color-spaces-in-javascript/#article-header-id-3
5 | const hexaToRgba = (hex: string) => {
6 | let r: string | number = 0;
7 | let g: string | number = 0;
8 | let b: string | number = 0;
9 | let a: string | number = 1;
10 |
11 | if (hex.length === 4) {
12 | r = "0x" + hex[0] + hex[0];
13 | g = "0x" + hex[1] + hex[1];
14 | b = "0x" + hex[2] + hex[2];
15 | a = "0x" + hex[3] + hex[3];
16 | } else if (hex.length === 5) {
17 | r = "0x" + hex[1] + hex[1];
18 | g = "0x" + hex[2] + hex[2];
19 | b = "0x" + hex[3] + hex[3];
20 | a = "0x" + hex[4] + hex[4];
21 | } else if (hex.length === 8) {
22 | r = "0x" + hex[0] + hex[1];
23 | g = "0x" + hex[2] + hex[3];
24 | b = "0x" + hex[4] + hex[5];
25 | a = "0x" + hex[6] + hex[7];
26 | } else if (hex.length === 9) {
27 | r = "0x" + hex[1] + hex[2];
28 | g = "0x" + hex[3] + hex[4];
29 | b = "0x" + hex[5] + hex[6];
30 | a = "0x" + hex[7] + hex[8];
31 | }
32 | a = +((a as number) / 255).toFixed(3);
33 |
34 | return [+r, +g, +b, +a];
35 | };
36 |
37 | type Deg = number;
38 | type Rad = number;
39 | type Turn = number;
40 | type Hue = Deg | Rad | Turn;
41 |
42 | const stringToHue = (input: string): Hue => {
43 | const inputAsNum = Number(input.substr(0, input.length - 3));
44 |
45 | if (input.indexOf("deg") > -1) {
46 | return inputAsNum;
47 | } else if (input.indexOf("rad") > -1) {
48 | return Math.round(inputAsNum * (180 / Math.PI));
49 | } else if (input.indexOf("turn") > -1) {
50 | return Math.round(Number(input.substr(0, input.length - 4)) * 360);
51 | } else {
52 | return Number(input);
53 | }
54 | };
55 |
56 | // @TODO untangle this type
57 | const hslaToRgba = (hslaArg: any): number[] => {
58 | const sep: string = hslaArg.indexOf(",") > -1 ? "," : " ";
59 |
60 | const hsla: any = hslaArg
61 | .substr(5)
62 | .split(")")[0]
63 | .split(sep);
64 |
65 | // console.log(typeof hsla);
66 | // console.log(hsla);
67 |
68 | if (hsla.indexOf("/") > -1) hsla.splice(3, 1);
69 |
70 | let h: Hue = stringToHue(hsla[0]);
71 | let s = parseInt(hsla[1].substr(0, hsla[1].length - 1)) / 100;
72 | let l = parseInt(hsla[2].substr(0, hsla[2].length - 1)) / 100;
73 | let a = hsla[3];
74 |
75 | // Keep hue fraction of 360 if ending up over
76 | if (h >= 360) {
77 | h %= 360;
78 | }
79 |
80 | let c = (1 - Math.abs(2 * l - 1)) * s,
81 | x = c * (1 - Math.abs(((h / 60) % 2) - 1)),
82 | m = l - c / 2,
83 | r = 0,
84 | g = 0,
85 | b = 0;
86 | if (0 <= h && h < 60) {
87 | r = c;
88 | g = x;
89 | b = 0;
90 | } else if (60 <= h && h < 120) {
91 | r = x;
92 | g = c;
93 | b = 0;
94 | } else if (120 <= h && h < 180) {
95 | r = 0;
96 | g = c;
97 | b = x;
98 | } else if (180 <= h && h < 240) {
99 | r = 0;
100 | g = x;
101 | b = c;
102 | } else if (240 <= h && h < 300) {
103 | r = x;
104 | g = 0;
105 | b = c;
106 | } else if (300 <= h && h < 360) {
107 | r = c;
108 | g = 0;
109 | b = x;
110 | }
111 | r = Math.round((r + m) * 255);
112 | g = Math.round((g + m) * 255);
113 | b = Math.round((b + m) * 255);
114 |
115 | return [+r, +g, +b, +a];
116 | };
117 |
118 | const rgbaToRgba = (rgba: any) => {
119 | const sep = rgba.indexOf(",") > -1 ? "," : " ";
120 |
121 | rgba = rgba
122 | .substr(5)
123 | .split(")")[0]
124 | .split(sep);
125 |
126 | const r = rgba[0];
127 | const g = rgba[1];
128 | const b = rgba[2];
129 | const a = rgba[3];
130 |
131 | return [+r, +g, +b, +a];
132 | };
133 |
134 | const toRgba = (color: string) => {
135 | switch (true) {
136 | case typeOfColor(color) === "hex4":
137 | case typeOfColor(color) === "hex8":
138 | return hexaToRgba(color);
139 |
140 | case typeOfColor(color) === "rgba":
141 | return rgbaToRgba(color);
142 |
143 | case typeOfColor(color) === "hsla":
144 | return hslaToRgba(color);
145 |
146 | // case typeOfColor(color) === "named":
147 | // return namedToRgba(color);
148 |
149 | default:
150 | return undefined;
151 | }
152 | };
153 |
154 | export { hexaToRgba, hslaToRgba, rgbaToRgba, toRgba };
155 |
--------------------------------------------------------------------------------
/src/contrast-triangle-logo.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/utils/to-rgba.test.ts:
--------------------------------------------------------------------------------
1 | import { hexaToRgba, hslaToRgba, rgbaToRgba, toRgba } from "./to-rgba";
2 |
3 | describe("HexA to RGBA conversion", () => {
4 | it("correct rgba for black", () => {
5 | expect(hexaToRgba("#00000000")[0]).toBe(0);
6 | expect(hexaToRgba("#00000000")[1]).toBe(0);
7 | expect(hexaToRgba("#00000000")[2]).toBe(0);
8 | expect(hexaToRgba("#00000000")[3]).toBe(0);
9 |
10 | expect(hexaToRgba("00000000")[0]).toBe(0);
11 | expect(hexaToRgba("00000000")[1]).toBe(0);
12 | expect(hexaToRgba("00000000")[2]).toBe(0);
13 | expect(hexaToRgba("00000000")[3]).toBe(0);
14 |
15 | expect(hexaToRgba("#0000")[0]).toBe(0);
16 | expect(hexaToRgba("#0000")[1]).toBe(0);
17 | expect(hexaToRgba("#0000")[2]).toBe(0);
18 | expect(hexaToRgba("#0000")[3]).toBe(0);
19 |
20 | expect(hexaToRgba("0000")[0]).toBe(0);
21 | expect(hexaToRgba("0000")[1]).toBe(0);
22 | expect(hexaToRgba("0000")[2]).toBe(0);
23 | expect(hexaToRgba("0000")[3]).toBe(0);
24 | });
25 |
26 | it("correct rgba for white", () => {
27 | expect(hexaToRgba("#ffffff00")[0]).toBe(255);
28 | expect(hexaToRgba("#ffffff00")[1]).toBe(255);
29 | expect(hexaToRgba("#ffffff00")[2]).toBe(255);
30 | expect(hexaToRgba("#ffffff00")[3]).toBe(0);
31 |
32 | expect(hexaToRgba("ffffff00")[0]).toBe(255);
33 | expect(hexaToRgba("ffffff00")[1]).toBe(255);
34 | expect(hexaToRgba("ffffff00")[2]).toBe(255);
35 | expect(hexaToRgba("ffffff00")[3]).toBe(0);
36 |
37 | expect(hexaToRgba("#fff0")[0]).toBe(255);
38 | expect(hexaToRgba("#fff0")[1]).toBe(255);
39 | expect(hexaToRgba("#fff0")[2]).toBe(255);
40 | expect(hexaToRgba("#fff0")[3]).toBe(0);
41 |
42 | expect(hexaToRgba("fff0")[0]).toBe(255);
43 | expect(hexaToRgba("fff0")[1]).toBe(255);
44 | expect(hexaToRgba("fff0")[2]).toBe(255);
45 | expect(hexaToRgba("fff0")[3]).toBe(0);
46 | });
47 |
48 | it("correct rgba for hotpink", () => {
49 | expect(hexaToRgba("#ff69b400")[0]).toBe(255);
50 | expect(hexaToRgba("#ff69b400")[1]).toBe(105);
51 | expect(hexaToRgba("#ff69b400")[2]).toBe(180);
52 | expect(hexaToRgba("#ff69b400")[3]).toBe(0);
53 | });
54 | });
55 |
56 | describe("HSLa to RGBa conversion", () => {
57 | it("correct rgb for black", () => {
58 | expect(hslaToRgba("hsla(0, 0%, 0%, 1)")[0]).toBe(0);
59 | expect(hslaToRgba("hsla(0, 0%, 0%, 1)")[1]).toBe(0);
60 | expect(hslaToRgba("hsla(0, 0%, 0%, 1)")[2]).toBe(0);
61 | expect(hslaToRgba("hsla(0, 0%, 0%, 1)")[3]).toBe(1);
62 | });
63 |
64 | it("correct rgba for white", () => {
65 | expect(hslaToRgba("hsla(0, 0%, 100%, 1)")[0]).toBe(255);
66 | expect(hslaToRgba("hsla(0, 0%, 100%, 1)")[1]).toBe(255);
67 | expect(hslaToRgba("hsla(0, 0%, 100%, 1)")[2]).toBe(255);
68 | expect(hslaToRgba("hsla(0, 0%, 100%, 1)")[3]).toBe(1);
69 | });
70 | });
71 |
72 | describe("rgba to RGBA conversion", () => {
73 | it("correct rgb for black", () => {
74 | expect(rgbaToRgba("rgba(0, 0, 0, 1)")[0]).toBe(0);
75 | expect(rgbaToRgba("rgba(0, 0, 0, 1)")[1]).toBe(0);
76 | expect(rgbaToRgba("rgba(0, 0, 0, 1)")[2]).toBe(0);
77 | expect(rgbaToRgba("rgba(0, 0, 0, 1)")[3]).toBe(1);
78 | });
79 |
80 | it("correct rgba for white", () => {
81 | expect(rgbaToRgba("rgba(255, 255, 255, 1)")[0]).toBe(255);
82 | expect(rgbaToRgba("rgba(255, 255, 255, 1)")[1]).toBe(255);
83 | expect(rgbaToRgba("rgba(255, 255, 255, 1)")[2]).toBe(255);
84 | expect(rgbaToRgba("rgba(255, 255, 255, 1)")[3]).toBe(1);
85 | });
86 | });
87 |
88 | // integration
89 | describe("To RGB conversion", () => {
90 | it("correct rgba for black", () => {
91 | expect(toRgba("hsla(0, 0%, 0%, 1)")).toEqual([0, 0, 0, 1]);
92 | expect(toRgba("rgba(0, 0, 0, 1)")).toEqual([0, 0, 0, 1]);
93 | expect(toRgba("#000000ff")).toEqual([0, 0, 0, 1]);
94 | expect(toRgba("#00000000")).toEqual([0, 0, 0, 0]);
95 | });
96 |
97 | it("correct rgba for white", () => {
98 | expect(toRgba("hsla(0, 0%, 100%, 1)")).toEqual([255, 255, 255, 1]);
99 | expect(toRgba("rgba(255, 255, 255, 1)")).toEqual([255, 255, 255, 1]);
100 | expect(toRgba("#ffffffff")).toEqual([255, 255, 255, 1]);
101 | expect(toRgba("#ffffff00")).toEqual([255, 255, 255, 0]);
102 | });
103 |
104 | it("get undefined for colors without alpha channels", () => {
105 | expect(toRgba("hsl(0, 0%, 100%)")).toBeUndefined();
106 | expect(toRgba("rgb(255, 255, 255)")).toBeUndefined();
107 | expect(toRgba("white")).toBeUndefined();
108 | expect(toRgba("foo")).toBeUndefined();
109 | });
110 | });
111 |
--------------------------------------------------------------------------------
/src/utils/to-rgb.ts:
--------------------------------------------------------------------------------
1 | import { typeOfColor } from "./type-of-color";
2 |
3 | // handles #000 or #000000
4 | // based on this function: https://css-tricks.com/converting-color-spaces-in-javascript/#article-header-id-3
5 | const hexToRgb = (hex: string): Array => {
6 | let r: number = 0;
7 | let g: number = 0;
8 | let b: number = 0;
9 |
10 | // 3 digits - fff
11 | if (hex.length === 3) {
12 | r = parseInt("0x" + hex[0] + hex[0]);
13 | g = parseInt("0x" + hex[1] + hex[1]);
14 | b = parseInt("0x" + hex[2] + hex[2]);
15 | } else if (hex.length === 4) {
16 | // #fff
17 | r = parseInt("0x" + hex[1] + hex[1]);
18 | g = parseInt("0x" + hex[2] + hex[2]);
19 | b = parseInt("0x" + hex[3] + hex[3]);
20 | } else if (hex.length === 6) {
21 | // ffffff
22 | r = parseInt("0x" + hex[0] + hex[1]);
23 | g = parseInt("0x" + hex[2] + hex[3]);
24 | b = parseInt("0x" + hex[4] + hex[5]);
25 | } else if (hex.length === 7) {
26 | // #ffffff
27 | r = parseInt("0x" + hex[1] + hex[2]);
28 | g = parseInt("0x" + hex[3] + hex[4]);
29 | b = parseInt("0x" + hex[5] + hex[6]);
30 | }
31 |
32 | return [+r, +g, +b];
33 | };
34 |
35 | const hslToRgb = (hsl: string): Array => {
36 | const sep = hsl.indexOf(",") > -1 ? "," : " ";
37 |
38 | let hslArray: Array = hsl
39 | .substr(4)
40 | .split(")")[0]
41 | .split(sep);
42 |
43 | let h = hslArray[0]; // leaving this a string for now
44 | let s = parseInt(hslArray[1].substr(0, hslArray[1].length - 1)) / 100 || 0;
45 | let l = parseInt(hslArray[2].substr(0, hslArray[2].length - 1)) / 100 || 0;
46 |
47 | let hNum: number;
48 |
49 | // Strip label and convert to degrees (if necessary)
50 | if (h.indexOf("deg") > -1) {
51 | hNum = parseInt(h.substr(0, h.length - 3));
52 | } else if (h.indexOf("rad") > -1) {
53 | hNum = Math.round(parseInt(h.substr(0, h.length - 3)) * (180 / Math.PI));
54 | } else if (h.indexOf("turn") > -1) {
55 | hNum = Math.round(parseInt(h.substr(0, h.length - 4)) * 360);
56 | } else {
57 | hNum = parseInt(h);
58 | }
59 |
60 | // Keep hue fraction of 360 if ending up over
61 | if (hNum >= 360) {
62 | hNum %= 360;
63 | }
64 |
65 | let c = (1 - Math.abs(2 * l - 1)) * s;
66 | let x = c * (1 - Math.abs(((hNum / 60) % 2) - 1));
67 | let m = l - c / 2;
68 | let r = 0;
69 | let g = 0;
70 | let b = 0;
71 | if (0 <= hNum && hNum < 60) {
72 | r = c;
73 | g = x;
74 | b = 0;
75 | } else if (60 <= hNum && hNum < 120) {
76 | r = x;
77 | g = c;
78 | b = 0;
79 | } else if (120 <= hNum && hNum < 180) {
80 | r = 0;
81 | g = c;
82 | b = x;
83 | } else if (180 <= hNum && hNum < 240) {
84 | r = 0;
85 | g = x;
86 | b = c;
87 | } else if (240 <= hNum && hNum < 300) {
88 | r = x;
89 | g = 0;
90 | b = c;
91 | } else if (300 <= hNum && hNum < 360) {
92 | r = c;
93 | g = 0;
94 | b = x;
95 | }
96 | r = Math.round((r + m) * 255);
97 | g = Math.round((g + m) * 255);
98 | b = Math.round((b + m) * 255);
99 |
100 | return [+r, +g, +b];
101 | };
102 |
103 | const rgbToRgb = (rgb: string): Array => {
104 | const sep = rgb.indexOf(",") > -1 ? "," : " ";
105 |
106 | const rgbArray = rgb
107 | .substr(4)
108 | .split(")")[0]
109 | .split(sep);
110 |
111 | const r = rgbArray[0];
112 | const g = rgbArray[1];
113 | const b = rgbArray[2];
114 |
115 | return [+r, +g, +b];
116 | };
117 |
118 | const namedToRgb = (name: string) => {
119 | // Create fake div
120 | let fakeDiv = document.createElement("div");
121 | fakeDiv.style.color = name;
122 | document.body.appendChild(fakeDiv);
123 |
124 | // Get color of div
125 | let cs = window.getComputedStyle(fakeDiv);
126 | let pv = cs.getPropertyValue("color");
127 |
128 | // Remove div after obtaining desired color value
129 | document.body.removeChild(fakeDiv);
130 | return rgbToRgb(pv);
131 | };
132 |
133 | const toRgb = (color: string): Array => {
134 | switch (true) {
135 | case typeOfColor(color) === "hex3":
136 | case typeOfColor(color) === "hex6":
137 | return hexToRgb(color);
138 |
139 | case typeOfColor(color) === "rgb":
140 | return rgbToRgb(color);
141 |
142 | case typeOfColor(color) === "hsl":
143 | return hslToRgb(color);
144 |
145 | case typeOfColor(color) === "named":
146 | return namedToRgb(color);
147 |
148 | default:
149 | // assume rgb to rgb
150 | return rgbToRgb(color);
151 | }
152 | };
153 |
154 | export { hexToRgb, hslToRgb, rgbToRgb, namedToRgb, toRgb };
155 |
--------------------------------------------------------------------------------
/src/utils/to-hex.ts:
--------------------------------------------------------------------------------
1 | import { typeOfColor } from "./type-of-color";
2 |
3 | // normalizes non-alpha hex values
4 | // handles 000, #000, 000000 or #000000
5 | const hexToHex = (hex: string): string => {
6 | let result = "";
7 |
8 | // fff
9 | if (hex.length === 3) {
10 | result = `#${hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]}`;
11 | } else if (hex.length === 4) {
12 | // #fff
13 | result = `#${hex[1] + hex[1] + hex[2] + hex[2] + hex[3] + hex[3]}`;
14 | } else if (hex.length === 6) {
15 | // ffffff
16 | result = `#${hex[0] + hex[1] + hex[2] + hex[3] + hex[4] + hex[5]}`;
17 | } else if (hex.length === 7) {
18 | // #ffffff
19 | result = `#${hex[1] + hex[2] + hex[3] + hex[4] + hex[5] + hex[6]}`;
20 | }
21 |
22 | return result;
23 | };
24 |
25 | // based on https://css-tricks.com/converting-color-spaces-in-javascript/#article-header-id-19
26 | const hslToHex = (hsl: string): string => {
27 | const sep = hsl.indexOf(",") > -1 ? "," : " ";
28 |
29 | let hslArray: Array = hsl
30 | .substr(4)
31 | .split(")")[0]
32 | .split(sep);
33 |
34 | let h = hslArray[0]; // leaving this a string for now
35 | let s = parseInt(hslArray[1].substr(0, hslArray[1].length - 1)) / 100 || 0;
36 | let l = parseInt(hslArray[2].substr(0, hslArray[2].length - 1)) / 100 || 0;
37 |
38 | let hNum: number;
39 |
40 | // Strip label and convert to degrees (if necessary)
41 | if (h.indexOf("deg") > -1) {
42 | hNum = parseInt(h.substr(0, h.length - 3));
43 | } else if (h.indexOf("rad") > -1) {
44 | hNum = Math.round(parseInt(h.substr(0, h.length - 3)) * (180 / Math.PI));
45 | } else if (h.indexOf("turn") > -1) {
46 | hNum = Math.round(parseInt(h.substr(0, h.length - 4)) * 360);
47 | } else {
48 | hNum = parseInt(h);
49 | }
50 |
51 | // Keep hue fraction of 360 if ending up over
52 | if (hNum >= 360) {
53 | hNum %= 360;
54 | }
55 |
56 | let c = (1 - Math.abs(2 * l - 1)) * s;
57 | let x = c * (1 - Math.abs(((hNum / 60) % 2) - 1));
58 | let m = l - c / 2;
59 | let r = 0;
60 | let g = 0;
61 | let b = 0;
62 |
63 | if (0 <= hNum && hNum < 60) {
64 | r = c;
65 | g = x;
66 | b = 0;
67 | } else if (60 <= hNum && hNum < 120) {
68 | r = x;
69 | g = c;
70 | b = 0;
71 | } else if (120 <= hNum && hNum < 180) {
72 | r = 0;
73 | g = c;
74 | b = x;
75 | } else if (180 <= hNum && hNum < 240) {
76 | r = 0;
77 | g = x;
78 | b = c;
79 | } else if (240 <= hNum && hNum < 300) {
80 | r = x;
81 | g = 0;
82 | b = c;
83 | } else if (300 <= hNum && hNum < 360) {
84 | r = c;
85 | g = 0;
86 | b = x;
87 | }
88 | // Having obtained RGB, convert channels to hex
89 | let rHex = Math.round((r + m) * 255).toString(16);
90 | let gHex = Math.round((g + m) * 255).toString(16);
91 | let bHex = Math.round((b + m) * 255).toString(16);
92 |
93 | // Prepend 0s, if necessary
94 | if (rHex.length === 1) rHex = "0" + rHex;
95 | if (gHex.length === 1) gHex = "0" + gHex;
96 | if (bHex.length === 1) bHex = "0" + bHex;
97 |
98 | return "#" + rHex + gHex + bHex;
99 | };
100 |
101 | // https://css-tricks.com/converting-color-spaces-in-javascript/#article-header-id-1
102 | const rgbToHex = (rgb: string): string => {
103 | let sep = rgb.indexOf(",") > -1 ? "," : " ";
104 |
105 | // Turn "rgb(r,g,b)" into [r,g,b]
106 | const rgbArray: Array = rgb
107 | .substr(4)
108 | .split(")")[0]
109 | .split(sep);
110 |
111 | let r = (+rgbArray[0]).toString(16);
112 | let g = (+rgbArray[1]).toString(16);
113 | let b = (+rgbArray[2]).toString(16);
114 |
115 | if (r.length === 1) r = "0" + r;
116 | if (g.length === 1) g = "0" + g;
117 | if (b.length === 1) b = "0" + b;
118 |
119 | return "#" + r + g + b;
120 | };
121 |
122 | const rgbArrayToHex = (rgb: Array) => {
123 | let r = (+Math.round(rgb[0])).toString(16),
124 | g = (+Math.round(rgb[1])).toString(16),
125 | b = (+Math.round(rgb[2])).toString(16);
126 |
127 | if (r.length === 1) r = "0" + r;
128 | if (g.length === 1) g = "0" + g;
129 | if (b.length === 1) b = "0" + b;
130 |
131 | return "#" + r + g + b;
132 | };
133 |
134 | const namedToHex = (name:string) => {
135 | // Create fake div
136 | let fakeDiv = document.createElement("div");
137 | fakeDiv.style.color = name;
138 | document.body.appendChild(fakeDiv);
139 |
140 | // Get color of div
141 | let cs = window.getComputedStyle(fakeDiv);
142 | let pv = cs.getPropertyValue("color");
143 |
144 | // Remove div after obtaining desired color value
145 | document.body.removeChild(fakeDiv);
146 | return rgbToHex(pv);
147 | };
148 |
149 | const toHex = (color:string) => {
150 | switch (true) {
151 | case typeOfColor(color) === "hex3":
152 | case typeOfColor(color) === "hex6":
153 | return hexToHex(color);
154 |
155 | case typeOfColor(color) === "rgb":
156 | return rgbToHex(color);
157 |
158 | case typeOfColor(color) === "hsl":
159 | return hslToHex(color);
160 |
161 | case typeOfColor(color) === "named":
162 | return namedToHex(color);
163 |
164 | default:
165 | // if nothing else, assume hex
166 | return hexToHex(color);
167 | }
168 | };
169 |
170 | export { hexToHex, hslToHex, rgbToHex, rgbArrayToHex, namedToHex, toHex };
171 |
--------------------------------------------------------------------------------
/src/serviceWorker.ts:
--------------------------------------------------------------------------------
1 | // This optional code is used to register a service worker.
2 | // register() is not called by default.
3 |
4 | // This lets the app load faster on subsequent visits in production, and gives
5 | // it offline capabilities. However, it also means that developers (and users)
6 | // will only see deployed updates on subsequent visits to a page, after all the
7 | // existing tabs open on the page have been closed, since previously cached
8 | // resources are updated in the background.
9 |
10 | // To learn more about the benefits of this model and instructions on how to
11 | // opt-in, read https://bit.ly/CRA-PWA
12 |
13 | const isLocalhost = Boolean(
14 | window.location.hostname === 'localhost' ||
15 | // [::1] is the IPv6 localhost address.
16 | window.location.hostname === '[::1]' ||
17 | // 127.0.0.0/8 are considered localhost for IPv4.
18 | window.location.hostname.match(
19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
20 | )
21 | );
22 |
23 | type Config = {
24 | onSuccess?: (registration: ServiceWorkerRegistration) => void;
25 | onUpdate?: (registration: ServiceWorkerRegistration) => void;
26 | };
27 |
28 | export function register(config?: Config) {
29 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
30 | // The URL constructor is available in all browsers that support SW.
31 | const publicUrl = new URL(
32 | process.env.PUBLIC_URL,
33 | window.location.href
34 | );
35 | if (publicUrl.origin !== window.location.origin) {
36 | // Our service worker won't work if PUBLIC_URL is on a different origin
37 | // from what our page is served on. This might happen if a CDN is used to
38 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374
39 | return;
40 | }
41 |
42 | window.addEventListener('load', () => {
43 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
44 |
45 | if (isLocalhost) {
46 | // This is running on localhost. Let's check if a service worker still exists or not.
47 | checkValidServiceWorker(swUrl, config);
48 |
49 | // Add some additional logging to localhost, pointing developers to the
50 | // service worker/PWA documentation.
51 | navigator.serviceWorker.ready.then(() => {
52 | console.log(
53 | 'This web app is being served cache-first by a service ' +
54 | 'worker. To learn more, visit https://bit.ly/CRA-PWA'
55 | );
56 | });
57 | } else {
58 | // Is not localhost. Just register service worker
59 | registerValidSW(swUrl, config);
60 | }
61 | });
62 | }
63 | }
64 |
65 | function registerValidSW(swUrl: string, config?: Config) {
66 | navigator.serviceWorker
67 | .register(swUrl)
68 | .then(registration => {
69 | registration.onupdatefound = () => {
70 | const installingWorker = registration.installing;
71 | if (installingWorker == null) {
72 | return;
73 | }
74 | installingWorker.onstatechange = () => {
75 | if (installingWorker.state === 'installed') {
76 | if (navigator.serviceWorker.controller) {
77 | // At this point, the updated precached content has been fetched,
78 | // but the previous service worker will still serve the older
79 | // content until all client tabs are closed.
80 | console.log(
81 | 'New content is available and will be used when all ' +
82 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.'
83 | );
84 |
85 | // Execute callback
86 | if (config && config.onUpdate) {
87 | config.onUpdate(registration);
88 | }
89 | } else {
90 | // At this point, everything has been precached.
91 | // It's the perfect time to display a
92 | // "Content is cached for offline use." message.
93 | console.log('Content is cached for offline use.');
94 |
95 | // Execute callback
96 | if (config && config.onSuccess) {
97 | config.onSuccess(registration);
98 | }
99 | }
100 | }
101 | };
102 | };
103 | })
104 | .catch(error => {
105 | console.error('Error during service worker registration:', error);
106 | });
107 | }
108 |
109 | function checkValidServiceWorker(swUrl: string, config?: Config) {
110 | // Check if the service worker can be found. If it can't reload the page.
111 | fetch(swUrl, {
112 | headers: { 'Service-Worker': 'script' }
113 | })
114 | .then(response => {
115 | // Ensure service worker exists, and that we really are getting a JS file.
116 | const contentType = response.headers.get('content-type');
117 | if (
118 | response.status === 404 ||
119 | (contentType != null && contentType.indexOf('javascript') === -1)
120 | ) {
121 | // No service worker found. Probably a different app. Reload the page.
122 | navigator.serviceWorker.ready.then(registration => {
123 | registration.unregister().then(() => {
124 | window.location.reload();
125 | });
126 | });
127 | } else {
128 | // Service worker found. Proceed as normal.
129 | registerValidSW(swUrl, config);
130 | }
131 | })
132 | .catch(() => {
133 | console.log(
134 | 'No internet connection found. App is running in offline mode.'
135 | );
136 | });
137 | }
138 |
139 | export function unregister() {
140 | if ('serviceWorker' in navigator) {
141 | navigator.serviceWorker.ready.then(registration => {
142 | registration.unregister();
143 | });
144 | }
145 | }
146 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import "./App.scss";
3 |
4 | import ColorInput from "./components/ColorInput";
5 | import PreviewParagraph from "./components/PreviewParagraph";
6 | import Results from "./components/Results";
7 | import ResultCard from "./components/ResultCard";
8 | import UnderlineControl from "./components/UnderlineControl";
9 | import Footer from "./components/Footer";
10 | import { colorTranslate } from "./utils/color-translate";
11 | import { isValidColor } from "./utils/type-of-color";
12 | import { checkYourSelfBeforeYouHexYourself } from "./utils/check-yourself-before-you-hex-yourself";
13 | import { useQueryString } from "./utils/useQueryString";
14 |
15 | import { ReactComponent as Logo } from "./contrast-triangle-logo.svg";
16 |
17 | import {
18 | ASSUMED_BACKGROUND_COLOR,
19 | DEFAULTBGCOLOR,
20 | DEFAULTTEXTCOLOR,
21 | DEFAULTLINKCOLOR,
22 | } from "./Constants";
23 |
24 | const App: React.FC = () => {
25 | const [bgColorQp, setBgColorQp] = useQueryString(`bgColor`);
26 | const [textColorQp, setTextColorQp] = useQueryString(`textColor`);
27 | const [linkColorQp, setLinkColorQp] = useQueryString(`linkColor`);
28 | const [underlinesQp, setUnderlinesQp] = useQueryString(`underlines`, false);
29 |
30 | // We need to set up background color state first
31 | const bgColorInitState: ColorObject = colorTranslate(
32 | // if the query parameter exists, use that, if not use default
33 | bgColorQp ? bgColorQp.toString() : DEFAULTBGCOLOR,
34 | ASSUMED_BACKGROUND_COLOR,
35 | true
36 | )
37 |
38 | const [bgColor, setBgColor] = useState(bgColorInitState);
39 |
40 | // we use this a lot
41 | const bgRgb = bgColor.rgb;
42 |
43 | // Then use background color state when initing the other colors
44 | const textColorInitState: ColorObject = colorTranslate(
45 | // if the query parameter exists, use that, if not use default
46 | textColorQp ? textColorQp.toString() : DEFAULTTEXTCOLOR,
47 | bgRgb,
48 | false
49 | )
50 | const [textColor, setTextColor] = useState(textColorInitState);
51 |
52 | const linkColorInitState: ColorObject = colorTranslate(
53 | // if the query parameter exists, use that, if not use default
54 | linkColorQp ? linkColorQp.toString() : DEFAULTLINKCOLOR,
55 | bgRgb
56 | )
57 | const [linkColor, setLinkColor] = useState(linkColorInitState);
58 |
59 | const textDecorationInitState =
60 | underlinesQp && underlinesQp === `true` ? `underlines` : `none`;
61 |
62 | const [textDecoration, setTextDecoration] = useState(textDecorationInitState);
63 |
64 | const handleTextColorChange = (color: string) => {
65 | if (color !== textColor.userValue && isValidColor(color)) {
66 | setTextColor(colorTranslate(color, bgRgb));
67 | setTextColorQp(color);
68 | }
69 | }
70 |
71 | const handleLinkColorChange = (color: string) => {
72 | if (color !== linkColor.userValue && isValidColor(color)) {
73 | setLinkColor(colorTranslate(color, bgRgb));
74 | setLinkColorQp(color);
75 | }
76 | }
77 |
78 | const handleBgColorChange = (color: string) => {
79 | if (color !== bgColor.userValue && isValidColor(color)) {
80 | // first set the background color
81 | setBgColor(colorTranslate(color, bgRgb, true));
82 | setBgColorQp(color);
83 |
84 | // then re-translate the text and link colors
85 | // if they have alpha values
86 | if (textColor.alpha) {
87 | setTextColor(colorTranslate(textColor.userValue, bgRgb));
88 | }
89 |
90 | if (linkColor.alpha) {
91 | setLinkColor(colorTranslate(linkColor.userValue, bgRgb));
92 | }
93 | }
94 | }
95 |
96 | const handleUnderlineChange = (checked: boolean) => {
97 | const underlineState = checked ? `underline` : `none`;
98 | setTextDecoration(underlineState);
99 | setUnderlinesQp(checked);
100 | };
101 |
102 | return (
103 |
111 |
112 |
117 |
126 |
127 |
134 |
141 |
148 |
152 |
161 |
170 |
179 |
180 |
181 |
187 |
188 |
189 |
198 |
199 | )
200 | }
201 |
202 | export default App;
203 |
--------------------------------------------------------------------------------
/src/App.scss:
--------------------------------------------------------------------------------
1 | $bp: 800px;
2 |
3 | body {
4 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
5 | Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
6 | }
7 |
8 | .app {
9 | padding: 3.75vw 0;
10 | height: 100%;
11 | min-height: 100vh;
12 | }
13 |
14 | .app__inner {
15 | max-width: 1000px;
16 | margin: 0 auto;
17 | padding: 20px;
18 | display: grid;
19 | grid-template-columns: 1fr;
20 | grid-template-areas:
21 | "title"
22 | "preview"
23 | "controls";
24 | justify-items: center;
25 |
26 | @media (min-width: $bp) {
27 | grid-template-columns: 1fr 2fr;
28 | grid-template-areas:
29 | "title preview"
30 | "controls controls";
31 | }
32 | }
33 |
34 | header {
35 | grid-area: title;
36 | }
37 |
38 | .logo {
39 | max-width: 400px;
40 |
41 | svg {
42 | width: 100%;
43 | }
44 | }
45 |
46 | .preview-paragraph {
47 | width: calc(100% - 2rem);
48 | max-width: 37.5rem;
49 | margin: 0 auto 3.75vw auto;
50 | line-height: 1.4;
51 | }
52 |
53 | .controls {
54 | grid-area: controls;
55 | display: grid;
56 | grid-template-areas:
57 | "color-input-text"
58 | "color-input-link"
59 | "color-input-bg"
60 | "underline-control";
61 | align-items: center;
62 | justify-content: center;
63 |
64 | @media (min-width: $bp) {
65 | grid-template-columns: 0.5fr 0.5fr 0.5fr 1fr 0.5fr 0.5fr 0.5fr;
66 | grid-template-rows: 1fr 1fr 1fr;
67 |
68 | grid-template-areas:
69 | "color-input-text color-input-text . result-card-link-text . color-input-link color-input-link"
70 | " . result-card-text-bg . underline-control . result-card-link-bg ."
71 | " . . . color-input-bg . . .";
72 | }
73 | }
74 |
75 | .color-input {
76 | display: flex;
77 | margin-right: 1rem;
78 | margin-bottom: 0.5rem;
79 |
80 | label {
81 | font-weight: bold;
82 |
83 | span {
84 | display: block;
85 | }
86 | }
87 |
88 | .color-input__inputs-wrapper {
89 | display: flex;
90 | }
91 |
92 | [type="text"] {
93 | max-width: 10rem;
94 | font-size: 1rem;
95 | padding: 0.1rem 0.25rem;
96 | }
97 | }
98 |
99 | .color-input--text {
100 | grid-area: color-input-text;
101 | }
102 |
103 | .color-input--link {
104 | grid-area: color-input-link;
105 | }
106 |
107 | .color-input--bg {
108 | grid-area: color-input-bg;
109 | }
110 |
111 | .underline-control {
112 | grid-area: underline-control;
113 | text-align: center;
114 | }
115 |
116 | .Results {
117 | max-width: 18rem;
118 | margin: 2rem auto;
119 | list-style-type: none;
120 |
121 | border: 1px solid black;
122 | padding: 1rem;
123 |
124 | li {
125 | margin-bottom: 0.5rem;
126 | }
127 |
128 | small {
129 | display: block;
130 | }
131 |
132 | @media (min-width: $bp) {
133 | display: none;
134 | }
135 | }
136 |
137 | .result-card-wrapper {
138 | position: relative;
139 | }
140 |
141 | .result-card {
142 | display: none;
143 | z-index: 1;
144 | position: relative;
145 |
146 | @media (min-width: $bp) {
147 | display: block;
148 | }
149 |
150 | width: 150px;
151 | padding: 10px 20px 20px 20px;
152 | margin: 0 20px;
153 | clip-path: polygon(0% 0%, 100% 0%, 50% 100%);
154 | background: lightgray;
155 | text-align: center;
156 |
157 | h2 {
158 | margin: 0 0 10px 0;
159 | font-size: 1.2rem;
160 |
161 | span {
162 | display: block;
163 | }
164 | }
165 | }
166 |
167 | @mixin arrow-before($top: 50%, $left: -20px, $rotation: 0) {
168 | &::before {
169 | content: "";
170 | display: block;
171 | width: 110px;
172 | height: 33px;
173 | background-image: url("./arrow-left.svg");
174 | background-repeat: no-repeat;
175 | background-size: cover;
176 | position: absolute;
177 | top: $top;
178 | left: $left;
179 | transform: rotate($rotation);
180 | opacity: 0.5;
181 | }
182 | }
183 |
184 | @mixin arrow-after($top: 50%, $right: 20px, $rotation: 0) {
185 | &::after {
186 | content: "";
187 | display: block;
188 | width: 110px;
189 | height: 33px;
190 | background-image: url("./arrow-right.svg");
191 | background-repeat: no-repeat;
192 | background-size: cover;
193 | position: absolute;
194 | top: $top;
195 | right: $right;
196 | transform: rotate($rotation);
197 | z-index: 0;
198 | opacity: 0.5;
199 | }
200 | }
201 |
202 | .result-card--link-text {
203 | grid-area: result-card-link-text;
204 |
205 | @media (min-width: $bp) {
206 | @include arrow-before();
207 | @include arrow-after();
208 | }
209 | }
210 |
211 | .result-card--bg-text {
212 | grid-area: result-card-text-bg;
213 |
214 | @media (min-width: $bp) {
215 | @include arrow-before(-10px, 50px, 60deg);
216 | @include arrow-after(110px, 0, 60deg);
217 | }
218 | }
219 |
220 | .result-card--bg-link {
221 | grid-area: result-card-link-bg;
222 |
223 | @media (min-width: $bp) {
224 | @include arrow-before(-10px, 50px, 120deg);
225 | @include arrow-after(110px, 130px, 120deg);
226 | }
227 | }
228 |
229 | .ResultEmoji {
230 | cursor: help;
231 | }
232 |
233 | // underline control
234 |
235 | $buttonsize: 32px;
236 |
237 | .underline-control__toggle {
238 | position: relative;
239 | height: $buttonsize;
240 | padding: 4px ($buttonsize / 2);
241 | transition: background-color 0.2s ease-in;
242 | border: 0;
243 | border-radius: ($buttonsize / 2);
244 | box-shadow: inset 0 0 1px 2px hsla(0, 0, 0, 0.2);
245 | color: white;
246 | font-size: 16px;
247 | text-align: center;
248 |
249 | // the circle thingy
250 | &::before {
251 | content: "";
252 | display: block;
253 | position: absolute;
254 | top: 2px;
255 | left: calc(50% - #{$buttonsize / 2});
256 | width: $buttonsize - 4;
257 | height: $buttonsize - 4;
258 | transition: transform 0.2s ease-in;
259 |
260 | @media (prefers-reduced-motion: reduce) {
261 | transition-duration: 0s;
262 | }
263 |
264 | border-radius: 50%;
265 | background-color: lightgray;
266 | filter: drop-shadow(0 1px 2px hsla(0, 0, 0, 0.3));
267 | }
268 |
269 | // the text labels
270 | .enabled,
271 | .disabled {
272 | position: relative;
273 | transition: opacity 0.1s ease-in;
274 | transition-delay: 0.1s;
275 |
276 | @media (prefers-reduced-motion: reduce) {
277 | transition-duration: 0s;
278 | transition-delay: 0s;
279 | }
280 |
281 | pointer-events: none;
282 | }
283 |
284 | .enabled {
285 | left: 0;
286 | }
287 |
288 | .disabled {
289 | left: 0;
290 | }
291 |
292 | &[aria-checked="true"] {
293 | background-color: black;
294 |
295 | &::before {
296 | transform: translateX(24px);
297 | }
298 |
299 | .enabled {
300 | opacity: 1;
301 | }
302 |
303 | .disabled {
304 | opacity: 0;
305 | }
306 | }
307 |
308 | &[aria-checked="false"] {
309 | background-color: #666;
310 |
311 | &::before {
312 | transform: translateX(-20px);
313 | }
314 |
315 | .enabled {
316 | opacity: 0;
317 | }
318 |
319 | .disabled {
320 | opacity: 1;
321 | }
322 | }
323 | }
324 |
325 | footer {
326 | padding: 20px;
327 | text-align: center;
328 | }
329 |
--------------------------------------------------------------------------------