UNPKG

6.12 kBJavaScriptView Raw
1import { isVisible } from 'bootstrap-vue/src/utils/dom';
2import { COMMA, CONTRAST_LEVELS, labelColorOptions, focusableTags } from './constants';
3
4export function debounceByAnimationFrame(fn) {
5 let requestId;
6
7 return function debounced(...args) {
8 if (requestId) {
9 window.cancelAnimationFrame(requestId);
10 }
11 requestId = window.requestAnimationFrame(() => fn.apply(this, args));
12 };
13}
14
15export function throttle(fn) {
16 let frameId = null;
17
18 return (...args) => {
19 if (frameId) {
20 return;
21 }
22
23 frameId = window.requestAnimationFrame(() => {
24 fn(...args);
25 frameId = null;
26 });
27 };
28}
29
30export function rgbFromHex(hex) {
31 const cleanHex = hex.replace('#', '');
32 const rgb =
33 cleanHex.length === 3
34 ? cleanHex.split('').map((val) => val + val)
35 : cleanHex.match(/[\da-f]{2}/gi);
36 const [r, g, b] = rgb.map((val) => parseInt(val, 16));
37 return [r, g, b];
38}
39
40export function rgbFromString(color, sub) {
41 const rgb = color.substring(sub, color.length - 1).split(COMMA);
42 const [r, g, b] = rgb.map((i) => parseInt(i, 10));
43 return [r, g, b];
44}
45
46export function hexToRgba(hex, opacity = 1) {
47 const [r, g, b] = rgbFromHex(hex);
48
49 return `rgba(${r}, ${g}, ${b}, ${opacity})`;
50}
51
52export function toSrgb(value) {
53 const normalized = value / 255;
54 return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4;
55}
56
57export function relativeLuminance(rgb) {
58 // WCAG 2.1 formula: https://www.w3.org/TR/WCAG21/#dfn-relative-luminance
59 // -
60 // WCAG 3.0 will use APAC
61 // Using APAC would be the ultimate goal, but was dismissed by engineering as of now
62 // See https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/3418#note_1370107090
63 return 0.2126 * toSrgb(rgb[0]) + 0.7152 * toSrgb(rgb[1]) + 0.0722 * toSrgb(rgb[2]);
64}
65
66export function colorFromBackground(backgroundColor, contrastRatio = 2.4) {
67 let color;
68 const lightColor = rgbFromHex('#FFFFFF');
69 const darkColor = rgbFromHex('#1f1e24');
70
71 if (backgroundColor.startsWith('#')) {
72 color = rgbFromHex(backgroundColor);
73 } else if (backgroundColor.startsWith('rgba(')) {
74 color = rgbFromString(backgroundColor, 5);
75 } else if (backgroundColor.startsWith('rgb(')) {
76 color = rgbFromString(backgroundColor, 4);
77 }
78
79 const luminance = relativeLuminance(color);
80 const lightLuminance = relativeLuminance(lightColor);
81 const darkLuminance = relativeLuminance(darkColor);
82
83 const contrastLight = (lightLuminance + 0.05) / (luminance + 0.05);
84 const contrastDark = (luminance + 0.05) / (darkLuminance + 0.05);
85
86 // Using a default threshold contrast of 2.4 instead of 3
87 // as this will solve weird color combinations in the mid tones
88 return contrastLight >= contrastRatio || contrastLight > contrastDark
89 ? labelColorOptions.light
90 : labelColorOptions.dark;
91}
92
93export function getColorContrast(foreground, background) {
94 // Formula: http://www.w3.org/TR/2008/REC-WCAG20-20081211/#contrast-ratiodef
95 const backgroundLuminance = relativeLuminance(rgbFromHex(background)) + 0.05;
96 const foregroundLuminance = relativeLuminance(rgbFromHex(foreground)) + 0.05;
97
98 let score = backgroundLuminance / foregroundLuminance;
99 if (foregroundLuminance > backgroundLuminance) {
100 score = 1 / score;
101 }
102
103 const level = CONTRAST_LEVELS.find(({ min, max }) => {
104 return score >= min && score < max;
105 });
106
107 return {
108 score: (Math.round(score * 10) / 10).toFixed(1),
109 level,
110 };
111}
112
113export function uid() {
114 return Math.random().toString(36).substring(2);
115}
116
117/**
118 * Receives an element and validates that it can be focused
119 * @param { HTMLElement } The element we want to validate
120 * @return { boolean } Is the element focusable
121 */
122
123export function isElementFocusable(elt) {
124 if (!elt) return false;
125
126 const { tagName } = elt;
127
128 const isValidTag = focusableTags.includes(tagName);
129 const hasValidType = elt.getAttribute('type') !== 'hidden';
130 const isDisabled = elt.getAttribute('disabled') === '' || elt.getAttribute('disabled');
131 const hasValidZIndex = elt.getAttribute('z-index') !== '-1';
132 const isInvalidAnchorTag = tagName === 'A' && !elt.getAttribute('href');
133
134 return isValidTag && hasValidType && !isDisabled && hasValidZIndex && !isInvalidAnchorTag;
135}
136
137/**
138 * Receives an element and validates that it is reachable via sequential keyboard navigation
139 * @param { HTMLElement } The element to validate
140 * @return { boolean } Is the element focusable in a sequential tab order
141 */
142
143export function isElementTabbable(el) {
144 if (!el) return false;
145
146 const tabindex = parseInt(el.getAttribute('tabindex'), 10);
147 return tabindex > -1;
148}
149
150/**
151 * Receives an array of HTML elements and focus the first one possible
152 * @param { Array.<HTMLElement> } An array of element to potentially focus
153 * @return { undefined }
154 */
155
156export function focusFirstFocusableElement(elts) {
157 const focusableElt = elts.find((el) => isElementFocusable(el));
158
159 if (focusableElt) focusableElt.focus();
160}
161
162/**
163 * Returns true if the current environment is considered a development environment (it's not
164 * production or test).
165 *
166 * @returns {boolean}
167 */
168export function isDev() {
169 return !['test', 'production'].includes(process.env.NODE_ENV);
170}
171
172/**
173 * Prints a warning message to the console in non-test and non-production environments.
174 * @param {string} message message to print to the console
175 * @param {HTMLElement} element component that triggered the warning
176 */
177export function logWarning(message = '', element = '') {
178 if (message.length && isDev()) {
179 console.warn(message, element); // eslint-disable-line no-console
180 }
181}
182
183/**
184 * Stop default event handling and propagation
185 */
186export function stopEvent(
187 event,
188 { preventDefault = true, stopPropagation = true, stopImmediatePropagation = false } = {}
189) {
190 if (preventDefault) {
191 event.preventDefault();
192 }
193 if (stopPropagation) {
194 event.stopPropagation();
195 }
196 if (stopImmediatePropagation) {
197 event.stopImmediatePropagation();
198 }
199}
200
201/**
202 * Return an Array of visible items
203 */
204export function filterVisible(els) {
205 return (els || []).filter((el) => isVisible(el));
206}