Construindo um Letter Avatar
Código pronto você encontra aos montes e porque reinventar a roda?
Uma boa maneira de aprender algo, é pegar o que já existe e juntar com vários conceitos fazendo a sua própria implementação. Raramente eu faço tutoriais aqui, mas vou compartilhar com vocês a construção de Letter Avatar (Avatar com letras) usando React + SVG e forçando o alto contraste, para o texto deixando o component mais acessivel.
Um pouco sobre SVG
SVG é uma linguagem de marcação para vetores. Desenho vetores se aproveita de calculos geométricos e diferente de JPEG ou um PNG que usa mapeamento de bits ele não tem restrição de resolução. Logo é uma boa opção para usarmos na web, ainda mais quando boa parte dos navegadores oferecem suporte para renderizar ele.
Todo svg começar precisa de um tamanho definido e para isso vamos começar com o tamanho de 64px. Mais pra frente, vamos fazer algumas mudanças nisso
<svg height="64" width="64"></svg>
Tendo o tamanho definido, vamos desenhar nosso circulo, para isso vamos usar a tag <circle>
<svg height="64" width="64">
<circle cx="32" cy="32" r="32"/>
</svg>
Isso criar um circulo preto. Explicando rapidamente as propriedades da circle
, o cx
é o centro X da coordenada do nosso circulo e cy
centro da coordenada Y. r
é o nosso raio. (Não vamos precisar calcular o 𝛑, chupa geometria).
Bom agora vamos para a tag que vai ter o nosso texto e não poderia ser diferente de <text>
aqui já tem algumas propriedades do que conhecemos do css como font-family
e font-size
.
<svg height="64" width="64">
<circle cx="32" cy="32" r="32"/>
<text font-size="32" font-family="Arial, Helvetica, sans-serif" fill="white" text-anchor="middle" x="32" y="43"
>FS</text>
</svg>
Resultado:
Vamos explicar mesmo que algumas coisas serão o que vamos modificar com o React depois.
O tamanho e o tipo da fonte, pode ser o que quisermos, joguei 32 por ser um tamanho que fica esteticamente agradável, a propriedade fill
define a cor de texto que gostaríamos de usar e o X e Y são as coordenadas da caixa de texto dentro do SVG. O Y fica mal posicionado se definirmos como metade do tamanho da caixa.
Sobre o text-anchor
define o alinhamento de um texto a partir de um ponto `x` e y
que definimos com0 32 e 43.
Até aqui já temos um resultado parecido com o que queremos, mas ainda não falta criarmos ele de forma mais dinamica e também criar o nosso recurso de contraste.
Colocando isso no React
Essa parte recomendo muito a leitura desse meu outro texto e relembrar como usar o React com SVG. Se já está familiarizado vamos seguir.
const LetterAvatar = props => (
<svg height="64" width="64">
<circle cx="32" cy="32" r="32"/>
<text fontSize="32" fontFamily="Arial, Helvetica, sans-serif" fill="white" textAnchor="middle" x="32" y="43"
>FS</text>
</svg>
)
Com isso já temos nosso component sendo renderizado, mas muita coisa é repetiva e vamos usar nossas props.
Para isso defini que ao invés de um height
e width
, vamos ter um propriedade size
e também ao invés de fill
vamos ter uma bg-color
, sei que seria melhor termos algo menor, mas também é importante ter algo declarativo já que também vamos precisar de um text-color
.
O uso do nosso component seria assim
<LetterAvatar size="64" bgColor="#000" textColor="#FFF">
FS
</LetterAvatar>
Agora já sabemos quais props usar para o nosso component então vamos ao refactory.
const LetterAvatar = props => {
const halfSize = props.size / 2;
return (
<svg height={props.size} width={props.size}>
<circle cx={halfSize} cy={halfSize} r={halfSize}/>
<text
fontSize={halfSize}
fontFamily="Arial, Helvetica, sans-serif"
fill={props.bgColor}
textAnchor="middle"
x={halfSize}
y="43"
>
{props.children}
</text>
</svg>
);
}
Agora já podemos usar o LetterAvatar com React. Mas temos alguns problemas principalmente com o nosso eixo Y do texto. Precisamos colocar um valor arbritário o que deixa pouco flexivel o nosso component.
Para isso vamos adicionar a questão de porcentagem para nossos valores e usar uma função para calcular porcentagem de forma simples.
const getPercentFromValue = (percent, value) => (value * percent/100);
Isso já vai nos ajudar a criar os valores e ajustar o Y de uma forma melhor e também pegar os tamanhos deixando o código até mais legivel.
const getPercentFromValue = (percent, value) => (value * percent/100);
const LetterAvatar = props => {
const halfSize = getPercentFromValue(50, props.size);
return (
<svg height={props.size} width={props.size}>
<circle cx={halfSize} cy={halfSize} r={halfSize}/>
<text
font-size={halfSize}
font-family="Arial, Helvetica, sans-serif"
fill={props.bgColor}
text-anchor="middle"
x={halfSize}
y={getPercentFromValue(66, props.size)}
>
{props.children}
</text>
</svg>
);
}
E temos o seguinte resultado:
Até aqui atingimos 2 dos nossos 3 objetivos. Agora, vamos imaginar que queremos usar várias cores e também deixar que o LetterAvatar resolva o background e também a cor do texto com o constraste correto.
Contraste acessível
A WACG define taxas de contraste ideal para a acessibilidade de um texto, isso permite que pessoas com problemas de visão consiga identificar melhor o texto na tela.
Ela também define uma taxa ideal de pelo menos 4.5:1 para textos normais e 3:1 para textos grandes. Para facilitar no contraste você pode inverter arbitrariamente as cores para pretas ou brancas e usar javascript para definir a cor do texto.
Algo semelhante a isso:
rgb = [0, 0, 0]
const contrastRatio = Math.round(((parseInt(rgb[0]) * 299) + (parseInt(rgb[1]) * 587) + (parseInt(rgb[2]) * 114)) / 1000);
const textColor = (contrastRatio > 128) ? 'black' : 'white';
Inclusive recomendo o artigo em inglês sobre contraste com CSS, que tem esse calculo e algo além só usando CSS.
Nós vamos usar o calculo acima e criar algo além, que possa aceitar qualquer valor de cor, com excessão do hsl
e keywords para cores (fica a lição de casa talvez criar uma conversão para isso). É importante lembrar também que estamos trabalhando com SVG e não tem gradient background.
Sobre o calculo em si, ele tem alguns valores predefinidos de uma formula que calcula a transformação de valores RGB para YIQ.
Primeiro vamos detectar o tipo de cor é um atalho de hexadecimal, um hexadecimal comum ou tem valores de opacidade, é um rgb ou um rgba?
Aqui já podemos eliminar alguns cenários valores com opacidade próximos de 0 devem receber a cor branca como texto e valores próximo de 1 a cor preta. Isso se levar só em conta só a opacidade.
Então vamos criar um map para os cenários de cores possivel, como estamos lidando com a string de cada tipo de cor, podemos usar só o primeiro caracter para saber o tipo. Logo temos algo assim:
const colorTypes = new Map([
['r', 'rgb'],
['#', 'hex'],
['h', 'hsla']
]);
const getColorType = color => colorTypes.get(color[0]);
const getTextColor = color => {
const colorType = getColorType(color);
if (colorType === 'hsla') {
console.warn(new Error('hsla color is not supported due contrast ratio'));
return 'black';
}
}
No exemplo acima, também criamos uma outra função para pegar a cor do texto, alertamos caso seja uma cor do hsla
e colocamos o valor `black` como padrão.
Segundo vamos tratar os cenários com o tipo de cor hexadecimal, ele será o tratado primeiro cenário porque vamos precisar converter o hexdecimal para decimal e o cenário mais complicado, já que o outro é uma transformação mais simples.
Então vamos complementar um pouco mais ao código acima:
const colorTypes = new Map([
['r', 'rgb'],
['#', 'hex'],
['h', 'hsla']
]);
const getColorType = color => colorTypes.get(color[0]);
const getArrayValuesFromString = color =>
color.replace(/\W/gi, '').split('');
const reverseHexShort = color =>
color.reduce((acc, num) => {
acc.push(num);
acc.push(num);
return acc;
}, []);
const transformHexValues = color =>
color.reduce((acc, value, index) => {
if (!(index % 2)) return acc;
const decimalValue =
parseInt(`${color[index - 1]}${value}`, 16);
return (acc.push(decimalValue), acc);
}, []);
const convertColorHexToRGBArray = color => {
let normalizedHexColor;
const hexColorArray = getArrayValuesFromString(color);
if (hexColorArray.length <= 4) {
normalizedHexColor = reverseHexShort(hexColorArray);
}
return transformHexValues(normalizedHexColor || hexColorArray);
}
const getTextColor = color => {
const colorType = getColorType(color);
if (colorType === 'hsla') {
console.warn(new Error('hsla color is not supported due contrast ratio'));
return 'black';
}
if (colorType === 'hex') {
rgbColorArray = convertColorHexToRGBArray(color);
}
}
Como dá para perceber no nosso if (colorType === 'hex')
muita coisa começa a acontecer, principalmente quando chamamos o convertColorHexToRGBArray
. Então vamos para cada detalhe.
Primeiro precisamos é ter um array com os valores em hexadecimal que são uma string
, assim facilita a manipulação dos valores, por isso temos a função getArrayValuesFromString
, essa função tira o símbolo#
usado pelo hexdecimal e ficamos só com os números para fazer a conversão correta.
Agora precisamos verificar se o valor não é um hexadecimal encurtado e fazemos isso verificando o tamanho do array gerado, se estiver igual ou menor que quatro, é um valor encurtado. Se for o caso vamos para próxima etapa que é normalizar esse valor.
Para normalizar o valor vamos usar a função reverseHexShort
, essa função usar cria um novo array adicionado os valores que sofreram encurtamento.
Agora que temos o valor como deveríamos vamos converter ele para um array
semelhante ao que usaremos no nosso calculo e para isso vamos usar a função transformHexValues
, que também criamos.
A função transformHexValues
vai pegar nosso array
com strings
, concatenar os valores para serem o par númerico e converter para o decimal. Para o valor ['F', 'F', '0', '0', '0', '0']
temos com resultado [255, 0, 0]
, que facilitará para fazermos o nosso calculo.
Outro ponto é que os nossos valores de entrada da chamada transformHexValues
será os valores de normalizedHexColor
ou hexColorArray
, caso não tenha tido nenhuma conversão por ser um valor hexadecimal não encurtado.
Agora vamos para a nossa terceira etapa antes do cálculo de contraste, que é quando o valor já vem um rgb
ou rgba
. Que podemos ver complementando nosso código:
const colorTypes = new Map([
['r', 'rgb'],
['#', 'hex'],
['h', 'hsla']
]);
const getColorType = color => colorTypes.get(color[0]);
const getArrayValuesFromString = color =>
color.replace(/\W/gi, '').split('');
const reverseHexShort = color =>
color.reduce((acc, num) => {
acc.push(num);
acc.push(num);
return acc;
}, []);
const transformHexValues = color =>
color.reduce((acc, value, index) => {
if (!(index % 2)) return acc;
const decimalValue =
parseInt(`${color[index - 1]}${value}`, 16);
return (acc.push(decimalValue), acc);
}, []);
const convertColorHexToRGBArray = color => {
let normalizedHexColor;
const hexColorArray = getArrayValuesFromString(color);
if (hexColorArray.length <= 4) {
normalizedHexColor = reverseHexShort(hexColorArray);
}
return transformHexValues(normalizedHexColor || hexColorArray);
}
const getTextColor = color => {
const colorType = getColorType(color);
if (colorType === 'hsla') {
console.warn(new Error('hsla color is not supported due contrast ratio'));
return 'black';
}
if (colorType === 'hex') {
rgbColorArray = convertColorHexToRGBArray(color);
}
if (colorType === 'rgb') {
rgbColorArray = color.replace(/((rgb|a)|(\(|\)))/g, '')
.split(',')
.map(str => +str.trim());
}
}
Esse é bem mais fácil com ajuda de uma regex
, que vou explicar aqui. Como o nosso valor tem algumas padrões, podemos procurar por eles e dar um replace.
O primeiro padrão facilmente identificado é o rgb
, colocamos isso em nossa regex, mas ele poder ter um ou
para caso seja rgba
e conseguimos isso com o agrupador e o sinal de ou
, como resultado temos (rgb|a)
.
Agora temos um outro cenário que são os parenteses, como podemos buscar na string o valor rgb
ou os parenteses, vamos adicionar mais um agrupador para os parenteses, mas agora com uma diferença, já que usaremos a barra \
para o não confundir com a regex
. O resultado que temos agora é (\(|\))
.
E com isso vamos criar um terceiro grupo de captura, que fará o outro para os parenteses ou rgba. O resultado que temos é ((rgb|a)|((|)))
.
Agora temos uma string que já quase o nosso array, só precisando fazer o split
.
Com os valores ainda em string
eles podem ter espaço e apesar de serem números inteiros, não tem o tipo apropriado para isso e vamos usar o map nosso array para ajustar isso. Explicando ao função dentro do .map
após o split, o sinal de +
força conversão e o trim()
remove os espaços em branco.
Assim já temos o array no formato [255, 0, 0]
que precisamos para fazer o calculo de contraste.
Mas antes vamos ter uma quarta etapa, de nada adianta conseguirmos a cor correta se a cor contém valores de opacidade, ainda mais que menos opacidade significar menor contraste com o branco.
Então para isso vamos criar um if
e chamar uma função que retorna se tem ou não valores de opacidade e se são menores do que 30. Como podemos ver no código abaixo:
const colorTypes = new Map([
['r', 'rgb'],
['#', 'hex'],
['h', 'hsla']
]);
const getColorType = color => colorTypes.get(color[0]);
const getArrayValuesFromString = color =>
color.replace(/\W/gi, '').split('');
const reverseHexShort = color =>
color.reduce((acc, num) => {
acc.push(num);
acc.push(num);
return acc;
}, []);
const transformHexValues = color =>
color.reduce((acc, value, index) => {
if (!(index % 2)) return acc;
const decimalValue =
parseInt(`${color[index - 1]}${value}`, 16);
return (acc.push(decimalValue), acc);
}, []);
const convertColorHexToRGBArray = color => {
let normalizedHexColor;
const hexColorArray = getArrayValuesFromString(color);
if (hexColorArray.length <= 4) {
normalizedHexColor = reverseHexShort(hexColorArray);
}
return transformHexValues(normalizedHexColor || hexColorArray);
}
const isOpacityTransparent = opacity => {
if (!opacity) return false;
let opacityValue = opacity;
if (Math.floor(opacity) === 0) {
opacityValue = opacity * 100;
}
return opacityValue > 30;
}
const getTextColor = color => {
const colorType = getColorType(color);
if (colorType === 'hsla') {
console.warn(new Error('hsla color is not supported due contrast ratio'));
return 'black';
}
if (colorType === 'hex') {
rgbColorArray = convertColorHexToRGBArray(color);
}
if (colorType === 'rgb') {
rgbColorArray = color.replace(/((rgb|a)|(\(|\)))/g, '')
.split(',')
.map(str => +str.trim());
}
if (isOpacityTransparent(rgbColorArray[3])) {
return 'black';
}
}
Explicando a função isOpacityTransparent
, se não tem valores de opacidade podemos retornar logo false
.
Agora se tem o valor, precisamos verificar se é igual a zero para casos definidos no rgba em que não é usado inteiros e fazer a conversão. Fazemos essa conversão multiplicando por 254 que seria o equivalente do 0 até 255 do nosso hexadecimal.
Com os valores normalizados vamos verificar se o valor é menor do que 70.
Assim já temos o que precisamos para saber se a opacidade precisa de uma cor de texto preta ou branca.
Agora vamos finalmente ao nosso quinto e ultimo passo que é o getContrastRatio
para isso vamos passar o nosso array com valores já convertidos.
const colorTypes = new Map([
['r', 'rgb'],
['#', 'hex'],
['h', 'hsla']
]);
const getColorType = color => colorTypes.get(color[0]);
const getArrayValuesFromString = color =>
color.replace(/\W/gi, '').split('');
const reverseHexShort = color =>
color.reduce((acc, num) => {
acc.push(num);
acc.push(num);
return acc;
}, []);
const transformHexValues = color =>
color.reduce((acc, value, index) => {
if (!(index % 2)) return acc;
const decimalValue =
parseInt(`${color[index - 1]}${value}`, 16);
return (acc.push(decimalValue), acc);
}, []);
const convertColorHexToRGBArray = color => {
let normalizedHexColor;
const hexColorArray = getArrayValuesFromString(color);
if (hexColorArray.length <= 4) {
normalizedHexColor = reverseHexShort(hexColorArray);
}
return transformHexValues(normalizedHexColor || hexColorArray);
}
const isOpacityTransparent = opacity => {
if (!opacity) return false;
let opacityValue = opacity;
if (Math.floor(opacity) === 0) {
opacityValue = opacity * 100;
}
return opacityValue > 30;
}
const getContrastRatio = rgbColorArray => Math.round((
(parseInt(rgbColorArray[0]) * 299) +
(parseInt(rgbColorArray[1]) * 587) +
(parseInt(rgbColorArray[2]) * 114)
) / 1000);
const getTextColor = color => {
const colorType = getColorType(color);
if (colorType === 'hsla') {
console.warn(new Error('hsla color is not supported due contrast ratio'));
return 'black';
}
if (colorType === 'hex') {
rgbColorArray = convertColorHexToRGBArray(color);
}
if (colorType === 'rgb') {
rgbColorArray = color.replace(/((rgb|a)|(\(|\)))/g, '')
.split(',')
.map(str => +str.trim());
}
if (isOpacityTransparent(rgbColorArray[3])) {
return 'black';
}
const getContrastRatio = rgbColorArray => Math.round((
(parseInt(rgbColorArray[0]) * 299) +
(parseInt(rgbColorArray[1]) * 587) +
(parseInt(rgbColorArray[2]) * 114)
) / 1000);
return (getContrastRatio(rgbColorArray) > 128) ? 'black' : 'white'
}
A nossa função ela agora define com base no contraste correto e com o calculo que já tinha mostrado anteriormente no texto.
Com isso podemos ter as seguintes versões como vemos abaixo:
Com esse resultado, espero que vocês tenham gostado de entender um pouco de tudo que está envolvido em usar React com SVG e ainda cuidar um pouco da acessibilidade.
Se quiser ver o código completo você pode encontrar aqui nesse gist.
Obrigado e qualquer coisa só me chamar
O texto é só um resumo de vários assuntos se quiser se aprofundar:
https://pt.wikipedia.org/wiki/SVG
https://en.wikipedia.org/wiki/SVG
https://webaim.org/resources/contrastchecker/