107 lines
2.9 KiB
JavaScript
107 lines
2.9 KiB
JavaScript
// Faithful JS port of the Bootstrap 5.3 Sass color functions the button-variant
|
|
// mixin uses, so the "deep overlay" can recompute the per-component
|
|
// --bs-btn-* variables Bootstrap bakes at compile time. Pure module.
|
|
//
|
|
// shade-color(c, w%) = mix(black, c, w) (darken)
|
|
// tint-color(c, w%) = mix(white, c, w) (lighten)
|
|
// color-contrast(bg) -> #ffffff or #000000 (WCAG, min ratio 4.5, white first)
|
|
|
|
const WHITE = { r: 255, g: 255, b: 255 };
|
|
const BLACK = { r: 0, g: 0, b: 0 };
|
|
const MIN_CONTRAST_RATIO = 4.5;
|
|
|
|
|
|
// "#rgb" / "#rrggbb" -> {r,g,b}; null for anything else (named/rgb()/var()).
|
|
function parseHexColor(s) {
|
|
if (typeof s !== "string") {
|
|
return null;
|
|
}
|
|
const v = s.trim();
|
|
const m6 = /^#([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/.exec(v);
|
|
const m3 = /^#([0-9a-fA-F])([0-9a-fA-F])([0-9a-fA-F])$/.exec(v);
|
|
if (m6) {
|
|
return { r: parseInt(m6[1], 16), g: parseInt(m6[2], 16), b: parseInt(m6[3], 16) };
|
|
}
|
|
if (m3) {
|
|
return { r: parseInt(m3[1] + m3[1], 16), g: parseInt(m3[2] + m3[2], 16), b: parseInt(m3[3] + m3[3], 16) };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
|
|
function clampByte(n) {
|
|
return Math.min(255, Math.max(0, Math.round(n)));
|
|
}
|
|
|
|
|
|
function toHex(c) {
|
|
return "#" + [c.r, c.g, c.b].map((n) => clampByte(n).toString(16).padStart(2, "0")).join("");
|
|
}
|
|
|
|
|
|
function toRgbString(c) {
|
|
return `${clampByte(c.r)}, ${clampByte(c.g)}, ${clampByte(c.b)}`;
|
|
}
|
|
|
|
|
|
// Sass mix($c1, $c2, $weight): each channel = c1*w + c2*(1-w). weight is a fraction.
|
|
function mix(c1, c2, weight) {
|
|
return {
|
|
r: c1.r * weight + c2.r * (1 - weight),
|
|
g: c1.g * weight + c2.g * (1 - weight),
|
|
b: c1.b * weight + c2.b * (1 - weight),
|
|
};
|
|
}
|
|
|
|
|
|
function shade(c, percent) {
|
|
return mix(BLACK, c, percent / 100);
|
|
}
|
|
|
|
|
|
function tint(c, percent) {
|
|
return mix(WHITE, c, percent / 100);
|
|
}
|
|
|
|
|
|
function channelLuminance(c255) {
|
|
const c = c255 / 255;
|
|
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
|
}
|
|
|
|
|
|
function relLuminance(c) {
|
|
return 0.2126 * channelLuminance(c.r) + 0.7152 * channelLuminance(c.g) + 0.0722 * channelLuminance(c.b);
|
|
}
|
|
|
|
|
|
function contrastRatio(a, b) {
|
|
const la = relLuminance(a) + 0.05;
|
|
const lb = relLuminance(b) + 0.05;
|
|
return la > lb ? la / lb : lb / la;
|
|
}
|
|
|
|
|
|
// color-contrast($bg): Bootstrap checks white first then black, returning the
|
|
// first foreground whose contrast exceeds the min ratio (4.5), else the higher.
|
|
function colorContrast(bg) {
|
|
const c = typeof bg === "string" ? parseHexColor(bg) : bg;
|
|
if (!c) {
|
|
return "#000000";
|
|
}
|
|
const rw = contrastRatio(c, WHITE);
|
|
const rb = contrastRatio(c, BLACK);
|
|
if (rw > MIN_CONTRAST_RATIO) {
|
|
return "#ffffff";
|
|
}
|
|
if (rb > MIN_CONTRAST_RATIO) {
|
|
return "#000000";
|
|
}
|
|
return rw >= rb ? "#ffffff" : "#000000";
|
|
}
|
|
|
|
|
|
module.exports = {
|
|
WHITE, BLACK, parseHexColor, toHex, toRgbString, mix, shade, tint,
|
|
relLuminance, contrastRatio, colorContrast,
|
|
};
|