Construindo um Letter Avatar

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 cycentro 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:

FS

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 hsle 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

Foto por James Owen


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

SVG: Scalable Vector Graphics
Scalable Vector Graphics (SVG) are an XML-based markup language for describing two-dimensional based vector graphics. XML
&lt;circle&gt;
The circle SVG element is an SVG basic shape, used to draw circles based on a center point and a radius.
&lt;text&gt;
The SVG text element draws a graphics element consisting of text. It’s possible to apply a gradient, pattern, clipping path, mask, or filter to text, like any other SVG graphics element.
text-anchor
The text-anchor attribute is used to align (start-, middle- or end-alignment) a string of pre-formatted text or auto-wrapped text where the wrapping area is determined from the inline-size property relative to a given point.
Add React to a Website – React
A JavaScript library for building user interfaces

https://webaim.org/resources/contrastchecker/

https://www.w3.org/TR/AERT/#color-contrast

https://en.wikipedia.org/wiki/YIQ

How to convert decimal to hexadecimal in JavaScript
How do you convert decimal values to their hexadecimal equivalent in JavaScript?