Make it Accessible: No More Walls of Text in Angular
In this article, we are going to cover text rendering using HTML, and how easy it is to create blocks of non accessible text with Angular. This is part of a series that I'm continuously writing to share the things I've been learning about accessibility.
At my last talk about a11y in Angular, I met a few colleagues, and they asked me, "how do you deal with a text that has multiple paragraphs?" My first thought was, well, multiple <p>
tags, one for each paragraph. But let's face it, how common it is to have the static text in an Angular app? How probable is it that you will know the number of required paragraphs beforehand?
I ended up saying, "You know what? I'm gonna do this at home!" and, VOILA! This post was born. Let's get started by analyzing the problem.
The Problem
Content in an Angular Application usually comes from some kind of service, and for that reason, we are almost never sure of how long the content is, and how many paragraphs it has. I used to do something like <p>{{someTextContent}}</p>
but this means we always have a single paragraph, that leads us to a single wall of text making it impossible for screen reader users to navigate through paragraphs.
We could say that the problem is that there's no built-in mechanism to display the content divided by the paragraph in an Angular Template.
The Hypothesis
First thing to do is to create a shared component that will get the text content, split it by the line breaks (\n
) and wrap each entry in the resulting array of paragraphs with <p>
tag using the ngFor
directive.
If we are rendering the <p>
tags inside a component, Angular's view encapsulation will prohibit us from customizing them. We'll need to have some sort of mechanism for dynamically attaching styles to our <p>
tags. For this we can use the ngTemplateOutlet
directive.
The Implementation
In order to visualize the problem and to proof the hypothesis I wrote a super small app that displays the same block of text inside 2 different articles. We have to end up with one having default styling and another one having custom styles. The text we'll use for testing consists of 4 paragraphs with a placeholder data, after running the app you'll see that all paragraphs get concatenated.
We'll start by creating the TextComponent that will transform the raw text into actual paragraphs. I created a repository and it has a branch with the base state of the project, go ahead a clone that branch so we can do this together.
1. The Text Component
First we need to generate the component, as usual I'll let Angular CLI do it for me. You can do that by following these steps:
- Go to project's directory
- Execute
ng generate component --name=shared/components/text --export
That easily, you have the new component. We could create a SharedModule
, and declare the component there, but I wanted to keep it short and focused on the actual problem - making better texts.
Go to the src/app/shared/components/text/text.component.ts
file and change it to this:
import { Component, Input } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import { map } from 'rxjs/operators';
@Component({
selector: 'app-text',
templateUrl: './text.component.html',
styleUrls: ['./text.component.scss']
})
export class TextComponent {
private text$ = new BehaviorSubject('');
// Observable that emits a text content split
// by paragraph.
paragraphs$ = this.text$.asObservable().pipe(
map((content: string) =>
content
.split('\n')
.map((line: string) => line.trim())
.filter((line: string) => line)
)
);
// Input that receives the content, and emits it to the
// Subject every time it changes.
@Input() set innerContent(text: string) {
this.text$.next(text);
}
}
Now we have to make sure we render the paragraphs properly by using a combination of the ngFor
directive, and the async
pipe. Go to the src/app/shared/components/text/text.component.html
and do this:
<p *ngFor="let paragraph of paragraphs$ | async">{{ paragraph }}</p>
With that in place, it's just a matter of using our new component! Go to the src/app/app.component.html
, and do this:
<header>
<h1>Accessible Text</h1>
</header>
<main>
<article class="card">
<h2>Simple text</h2>
<app-text [innerContent]="simpleText"></app-text>
</article>
<article class="card">
<h2>Custom Text</h2>
<app-text [innerContent]="simpleText"></app-text>
</article>
</main>
2. It's time for customization
Now that our text is divided into paragraphs, someone could say we've got what we wanted. But if you are like me, then you probably want more power over this. How can we make that more customizable? The answer is ... DRUMS ... - ngTemplateOutlet
directive!
This can get tricky, I'm not going into details about ngTemplateOutlet
, if you think that the article is about it would be useful - just drop a comment below.
Being extremely brief, what ngTemplateOutlet
allows you is to attach a TemplateRef
to an element and give you the mechanism to provide it a context variable. In our case we'll add the <ng-template>
inside the TextComponent
, then we can access it using the ContentChild
decorator.
Let's start by creating our first custom <p>
. I want to do something fancy, so I'm gonna split the card content into two columns, and will make the first letter of the first paragraph larger and change its style. That means we will need something like this in our template:
<p class="paragraph" [ngClass]="{ first: first }">
{{ paragraph }}
</p>
Accompanied by some styles:
.paragraph {
background-color: #222233;
color: #aaccff;
margin: 0;
margin-bottom: 2rem;
text-align: justify;
text-indent: 2rem;
line-height: 2;
&.first {
&::first-letter {
font-size: 200%;
font-family: 'Times New Roman', Times, serif;
color: #bbddff;
}
}
}
We want to use this new element in our text, but if we do this directly in the TextComponent
, all the instances are going to be affected, we could make the .paragraph
class conditional and that would work but what if we want another style? We don't want to create another class that will also be conditional.
At this point we could pass the styles to the component as an @Input
property, but what about the ::first-letter
pseudo-element? We cannot assign it using inline style, nor with the ngStyle
directive.
We somehow need to be able to give the template
to the TextComponent
that will be used to render each paragraph. That way, each paragraph can have custom paragraphs. One thing to have in mind is that I still want to provide a clean <p>
tag as a default behavior.
Let's start by modifying the way we use the TextComponent
in the AppComponent
, so go ahead and change src/app/app.component.html
:
<main>
<!-- ... -->
<article class="card custom">
<h2 class="custom__title">Custom Text</h2>
<app-text [innerContent]="simpleText">
<ng-template #paragraphTemplate let-ctx>
<p class="custom__paragraph" [ngClass]="{ first: ctx.first }">
{{ ctx.paragraph }}
</p>
</ng-template>
</app-text>
</article>
<!-- ... -->
</main>
The actual change was that we added this to the content of the TextComponent
:
<ng-template #paragraphTemplate let-ctx>
<p class="custom__paragraph" [ngClass]="{ first: ctx.first }">
{{ ctx.paragraph }}
</p>
</ng-template>
Here, I'm creating a new template - you can hydrate the template with an information through the let-ctx
attribute. Note that the ctx
part is up to you, I just like using that name. When we use this template with the ngTemplateOutlet
, we are able to dynamically assign the value to ctx
.
Also, I've included the paragraph styles and some customizations into the .custom
class in src/app/app.component.scss
:
.custom {
font-family: Verdana, Geneva, Tahoma, sans-serif;
background-color: #111122;
color: #cceeff;
column-count: 2;
column-gap: 40px;
column-rule-style: solid;
column-rule-color: #cceeff;
&__title {
column-span: all;
text-align: center;
}
&__paragraph {
background-color: #222233;
color: #aaccff;
margin: 0;
margin-bottom: 2rem;
text-align: justify;
text-indent: 2rem;
line-height: 2;
&.first {
&::first-letter {
font-size: 200%;
font-family: 'Times New Roman', Times, serif;
color: #bbddff;
}
}
}
}
If you try it right now, you'll notice that nothing has changed, and the styles are not being applied. We need to give the TextComponent
the ability to access the template inside its content via paragraphTemplate
reference variable, and then using it with the ngTemplateOutlet
directive.
We'll start with the src/app/shared/components/text/text.component.ts
:
import { /* ... */ ContentChild, TemplateRef } from '@angular/core';
// ...
export class TextComponent {
@ContentChild('paragraphTemplate', { static: true })
paragraphTemplateRef: TemplateRef<any>;
// ...
}
To access a template that's part of the component's content, you can use the ContentChild
decorator. It will populate paragraphTemplate variable with the reference to the actual template.
Now that we have all in place, it's time to use it. Go to src/app/shared/components/text/text.component.html
:
<!-- Default template, in case it wasn't provided -->
<ng-template #defaultParagraphTemplate let-ctx>
<p>{{ ctx.paragraph }}</p>
</ng-template>
<!-- The actual rendering of the paragraphs -->
<ng-container
*ngFor="let paragraph of paragraphs$ | async; let first = first"
[ngTemplateOutlet]="paragraphTemplateRef || defaultParagraphTemplate"
[ngTemplateOutletContext]="{
$implicit: { first: first, paragraph: paragraph }
}"
>
</ng-container>
The first time I saw something like this, I was a bit confused, so lets go piece-by-piece. the ngTemplateOutlet
directive allows you to provide a template that will be rendered, so we are assigning the provided paragraphTemplateRef
. Since we want to have a default presentation I created a second template variable reference that's used when the user doesn't provide a custom template.
The other thing to notice is the ngTemplateOutletContext
, that's the mechanism provided by the Angular team to hydrate templates with data. The { first: first, paragraph: paragraph }
will be assigned to ctx
in the template.
Conclusion
You just did it, now you have a way to make sure your texts aren't super boring walls of text, even if they come from the server. And as a bonus, we made it highly customizable so you can reuse the strategy in any of your projects. If you want to learn more about ngTemplateOutlet
, you definitely have to watch this talk about ngTemplateOutlet
by Stephen Cooper, all the techniques with ngTemplateOutlet
I used, came from that talk.
Icons made by Smashicons from Flaticon
This Dot Inc. is a consulting company which contains two branches : the media stream, and labs stream. This Dot Media is the portion responsible for keeping developers up to date with advancements in the web platform. This Dot Labs provides teams with web platform expertise, using methods such as mentoring and training.