Вывод цветовой палитры в Storybook
Допустим, в проекте используются следующие цвета.
:root {
--color-primary: blue;
--color-secondary: orange;
}
Теперь попробуем вывести эти цвета в виде палитры.
Я хочу вывести нашу цветовую палитру в отдельном разделе — Colors. Для этого создадим файл ./docs/colors.tsx
.
import { Meta } from '@storybook/react';
export default {
title: 'Lego'
} as Meta;
export const Colors = () => (
<div>Color palette</div>
);
Colors.parameters = {
previewTabs: {
'storybook/docs/panel': {
hidden: true,
},
},
};
Поправим конфиг storybook в файле main.js
.
module.exports = {
'stories': [
'../docs/**/*.(tsx|mdx)',
'../src/components/**/*.stories.tsx',
],
'addons': [
'@storybook/addon-links',
'@storybook/addon-essentials',
],
'framework': '@storybook/react'
};
При запуске storybook мы увидим, что наша story успешно добавилась.

Основой нашего механизма будет функция getColorsFromCustomProperties
.
function getColorsFromCustomProperties() {
// ...
}
Давайте порассуждаем о ее реализации. Мы можем воспользоваться методом getPropertyValue
, но он ожидает на вход property name.
console.log(
getComputedStyle(document.documentElement)
.getPropertyValue('--color-parimary')
);
Мы не хотим завязываться на конкретные custom properties. Нам нужно вывести все доступные properties. Поэтому данный метод нам не подходит.
У глобального объекта document
есть свойство styleSheets
. Давайте посмотрим что оно содержит.

Отфильтруем таблицы стилей только для текущего домена.
function getColorsFromCustomProperties() {
const isCurrentDomain = (stylesheet: CSSStyleSheet) =>
!stylesheet.href || stylesheet.href.indexOf(window.location.origin) === 0;
return Array.from(document.styleSheets)
.filter(isCurrentDomain);
}
Далее пройдемся по каждому элементу CSSStyleSheet
. У каждого объекта нас будет интересовать свойство cssRules
. Пройдемся также по каждому CSSStyleRule
. Нас будут интересовать только rule с STYLE_RULE === 1, селектор которых начинается с :root
.
function getColorsFromCustomProperties() {
const isCurrentDomain = (stylesheet: CSSStyleSheet) =>
!stylesheet.href || stylesheet.href.indexOf(window.location.origin) === 0;
const isStyleRule = (rule: CSSStyleRule) => rule.STYLE_RULE === 1;
const isRootSelector = (rule: CSSStyleRule) => (rule.selectorText || '').startsWith(':root');
return Array.from(document.styleSheets)
.filter(isCurrentDomain)
.reduce((acc, stylesheet) => {
const rules = Array.from(stylesheet.cssRules)
.filter((rule: CSSStyleRule) => isStyleRule(rule) && isRootSelector(rule));
console.log(rules);
return acc;
}, []);
}
Отфильтруем свойства, начинающиеся с --color-
и соберем custom properties в массив.
function getColorsFromCustomProperties() {
const isCurrentDomain = (stylesheet: CSSStyleSheet) =>
!stylesheet.href || stylesheet.href.indexOf(window.location.origin) === 0;
const isStyleRule = (rule: CSSStyleRule) => rule.STYLE_RULE === 1;
const isRootSelector = (rule: CSSStyleRule) => (rule.selectorText || '').startsWith(':root');
const isColorProp = (prop: string) => prop.startsWith('--color-');
return Array.from(document.styleSheets)
.filter(isCurrentDomain)
.reduce((acc, stylesheet) => {
const rules = Array.from(stylesheet.cssRules)
.filter((rule: CSSStyleRule) => isStyleRule(rule) && isRootSelector(rule));
const properties = rules.reduce((allProperties, rule: CSSStyleRule) => [
...allProperties,
...Array.from(rule.style)
.filter(isColorProp)
.map((prop) => prop.trim()),
], []);
console.log(properties); // ["--color-primary", "--color-secondary"]
return acc;
}, []);
}
Прокинем собранные свойства в результирующий массив. Также допишем недостающие типы. Все, наша функция готова.
function getColorsFromCustomProperties() {
const isCurrentDomain = (stylesheet: CSSStyleSheet) =>
!stylesheet.href || stylesheet.href.indexOf(window.location.origin) === 0;
const isStyleRule = (rule: CSSStyleRule) => rule.STYLE_RULE === 1;
const isRootSelector = (rule: CSSStyleRule) => (rule.selectorText || '').startsWith(':root');
const isColorProp = (prop: string) => prop.startsWith('--color-');
return Array.from(document.styleSheets)
.filter(isCurrentDomain)
.reduce<string[]>((acc, stylesheet) => {
const rules = Array.from(stylesheet.cssRules)
.filter((rule: CSSStyleRule) => isStyleRule(rule) && isRootSelector(rule));
const properties = rules.reduce((allProperties: string[], rule: CSSStyleRule) => [
...allProperties,
...Array.from(rule.style)
.filter(isColorProp)
.map((prop) => prop.trim()),
], []);
if (properties.length) {
return [...acc, ...properties];
}
return acc;
}, []);
}
Теперь нам нужно установить в стейт компонента результат работы нашей функции. Но сделать это можно только после монтирования компонента. В противном случае, DOM нам будет недоступен и мы не соберем никакие custom properties.
export const Colors = () => {
const [properties, setProperties] = useState<string[]>([]);
useEffect(() => {
setProperties(getColorsFromCustomProperties());
}, []);
return (
<table>
{properties.map((prop) => (
<tr key={prop}>
<td>
<div
className="color-preview"
style={{ backgroundColor: `var(${prop})` }}
/>
</td>
<td>
<div className="property-name">{prop}</div>
</td>
</tr>
))}
</table>
);
};
Добавим щепотку стилей.
.color-preview {
width: 4em;
height: 2em;
border: solid 1px darkgray;
}
.property-name {
padding: 0.5em;
color: darkgray;
}
Вот что получилось.

Сделаем так, чтобы при клике по плашке цвета, в буфер обмена копировался сниппет, которым мы будем пользоваться при использовании данного цвета (например, var(--primary-color)
).
export const Colors = () => {
const [properties, setProperties] = useState<string[]>([]);
useEffect(() => {
setProperties(getColorsFromCustomProperties());
}, []);
const clickHandler = useCallback((e: MouseEvent<HTMLElement>) => {
if (navigator.clipboard) {
navigator.clipboard.writeText((e.target as HTMLElement).dataset.snippet);
}
}, []);
return (
<table>
{properties.map((prop) => (
<tr key={prop}>
<td>
<button
data-snippet={`var(${prop})`}
className="color-preview"
style={{ backgroundColor: `var(${prop})` }}
onClick={clickHandler}
/>
</td>
<td>
<div className="property-name">{prop}</div>
</td>
</tr>
))}
</table>
);
};
Добавим возможность фильтрации.
export const Colors = () => {
const [properties, setProperties] = useState<string[]>([]);
const cache = useRef<string[]>([]);
useEffect(() => {
cache.current = getColorsFromCustomProperties();
setProperties(cache.current);
}, []);
const filterHandler = useCallback((e: ChangeEvent<HTMLInputElement>) => {
const value = e.target.value.trim().toLowerCase();
const filtered = cache.current.filter((prop) => prop.includes(value));
setProperties(filtered);
}, []);
const clickHandler = useCallback((e: MouseEvent<HTMLElement>) => {
if (navigator.clipboard) {
navigator.clipboard.writeText((e.target as HTMLElement).dataset.snippet);
}
}, []);
return (
<>
<div>
<input
type="text"
placeholder="Введите название цвета"
onChange={filterHandler}
/>
</div>
<table>
{properties.map((prop) => (
<tr key={prop}>
<td>
<button
data-snippet={`var(${prop})`}
className="color-preview"
style={{ backgroundColor: `var(${prop})` }}
onClick={clickHandler}
/>
</td>
<td>
<div className="property-name">{prop}</div>
</td>
</tr>
))}
</table>
</>
);
};
getColorsFromCustomProperties
, т.к. она достаточно «тяжелая» и нет никакой необходимости вызывать ее при каждом изменении значения в поле поиска.Вот, что у нас получилось.
