Codziennością jest dla nas tworzenie nowych komponentów w angularowych aplikacjach. Korzystając z technik takich jak zaawansowane selektory dla dyrektyw oraz rozszerzanie natywnych elementów możemy osiągnąć interesujące rezultaty. W tym poście przyjrzymy się możliwościom, jakie dają nam angularowe selectory, uprościmy dzięki nim implementację komponentów i poprawimy wsparcie dla a11y.
„Components are the most basic UI building block of an Angular app.
An Angular app contains a tree of Angular components.”
Rzućmy okiem na dość prosty przykład i zobaczmy, w jaki sposób rozszerzanie natywnych elementów w Angularze może nam pomóc.
- Kod źródłowy i slajdy
- Tworzenie niestandardowego komponentu przycisku
2.1. Custom button #1
2.2. Custom button #2
2.3. Create a custom button #3 - Globalne atrybuty
- Augmenting native elements
4.1. Ponowne zaimplementowanie trzech przykładów przy użyciu podejścia opartego na rozszerzaniu elementów natywnych
4.2. Custom button #1
4.3. Custom button #2
4.4. Custom button #3
4.5. Dlaczego to działa?
4.6. Korzyści
4.7. Wiele popularnych bibliotek UI wykorzystuje podejście rozszerzania elementów natywnych
4.8. Co jeszcze można osiągnąć z augmentacją elementów natywnych?
4.9. Czy nigdy nie powinniśmy zastępować komponentów natywnych komponentami customowymi?
Kod źródłowy i slajdy
Ten wpis na blogu jest oparty na mojej ostatniej prezentacji na NGRome 2022.
- Zobacz slajdy z oryginalnej prezentacji → https://trungk18.com/ngromeconf-2022
- Wyświetl kod źródłowy dema → https://stackblitz.com/edit/angular-directives-use-case
Tworzenie niestandardowego komponentu przycisku
Stworzymy niestandardowy komponent przycisku z trzema wariantami
- Przycisk z prostym tekstem
- Przycisk z tekstem i ikoną
- Przycisk z tekstem i ikoną, zachowuje się jak tag
<a>
i otwiera nowy adres URL po kliknięciu.
stackblitz.com/edit/angular-directives-use-case ↗
Custom button #1
To jest prosta implementacja pierwszego wariantu, który renderuje przycisk z konkretnymi stylami i tekstem.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
export type ButtonType = 'reset' | 'button' | 'submit'; export type ButtonTheme = 'primary' | 'secondary'; @Component({ selector: 'shared-button', template: ` <button class="button" [ngClass]="'btn-' + buttonTheme" [attr.type]="buttonType"> <span class="button-text">{{ buttonText }}</span> </button> `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class ButtonComponent { @Input() buttonText!: string; @Input() buttonTheme: ButtonTheme = 'secondary'; @Input() buttonType: ButtonType = 'button'; } |
Aby stworzyć nowy komponent w angularze, potrzebujemy zazwyczaj co najmniej trzech części:
- Selektor, w tym przypadku jest to
shared-button
. - Szablon widoku,
- Klasę, która definiuje zachowanie naszego komponentu. W przypadku naszego przycisku, nie implementujemy żadnej dodatkowej logiki
Poniżej sposób, w jaki korzystamy z naszego komponentu. Wystarczy podać input [buttonText]
i będziemy mieli tekst Login
renderowany wewnątrz naszego przycisku.
A tak prezentuje się wyrenderowany komponent w DOM. Jak widzimy, istnieje element shared-button
, który wrappuje prawdziwy natywny element button
.
Custom button #2
W drugim przykładzie chcemy wyrenderować tekst wraz z ikoną. Najprostszym sposobem jest dodanie nowego @Input() icon: string
i nasz shared-button
z pomocą naszej ulubionej biblioteki ikon (np. material icon) wyrenderuje podaną ikonę. . Kod mógłby potencjalnie wyglądać tak:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
export type ButtonType = 'reset' | 'button' | 'submit'; export type ButtonTheme = 'primary' | 'secondary'; @Component({ selector: 'shared-button', template: ` <button class="button" [ngClass]="'btn-' + buttonTheme" [attr.type]="buttonType"> <span class="button-text">{{ buttonText }}</span> + <mat-icon [svgIcon]="buttonIcon"></mat-icon> </button> `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class ButtonComponent { @Input() buttonText!: string; + @Input() buttonIcon!: string; } |
Jednak przy tej implementacji zawsze istnieją pewne ograniczenia:
- Jesteśmy ograniczeni do korzystania z
mat-icon
. - Ikona zawsze wyświetla się za tekstem
Wraz z tym pojawia się kilka pytań:
- Co jeśli nie chcę używać
mat-icon
, ale chcę użyć tylko zwykłej font icon z prostym znacznikiem <i> ub obraz z tagiem <img>? - Co jeśli chcę umieścić ikonę przed tekstem?
Aby wspierać powyższe wymagania, o wiele łatwiej jest, jeśli możemy dostarczyć zarówno tekst, jak i ikony razem jako „szablon” do komponentu przycisku, tak, aby użytkownik komponentu mógł zdecydować, jak chce ułożyć treść, lub z jakiej biblioteki ikon chce skorzystać.
Wprowadzamy nowy @Input() buttonContent
, który akceptuje TemplateRef
zamiast zwykłego stringa. Możemy użyć czegoś takiego jak content projection, który robi to samo, jednak na razie trzymajmy się TemplateRef
.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
@Component({ selector: 'shared-button', template: ` <button class="button" [ngClass]="'btn-' + buttonTheme" [attr.type]="buttonType"> <span class="button-text"> <ng-container> {{ buttonText }} </ng-container> + <ng-container *ngTemplateOutlet="buttonContent"> </ng-container> </span> </button> `, }) export class ButtonComponent { + @Input() buttonContent!: TemplateRef<any>; @Input() buttonText!: string; @Input() buttonTheme: ButtonTheme = 'secondary'; @Input() buttonType: ButtonType = 'button'; } |
Nowa implementacja zapewnia elastyczność pod względem tego co chcemy renderować wewnątrz przycisku: może to być prosta ikona, moża być ikona pojawiająca się przed tekstem, możemy też skorzystać z innej biblioteki ikon (w przykładzie twitter-icon
).
1 2 3 4 5 6 7 |
<shared-button [buttonContent]="twitterBtnTmpl" > <ng-template #twitterBtnTmpl> Twitter <twitter-icon class="btn-icon"></twitter-icon> </ng-template> </shared-button> |
Poniżej widzimy wyrenderowany komponent w DOM. Nadal istnieje element shared-button
, który owija wrappuje natywny button jak w poprzednim przykładzie #1.
Create a custom button #3
W trzecim wariancie przycisku problemy stają się nieco bardziej interesujące. Chcemy mieć link, który wygląda jak przycisk. Oznacza to, że po kliknięciu chcemy otworzyć nowy adres URL.
Brzmi to dość prosto, jednak zaimplementowanie tego jest nieco bardziej skomplikowane.
Link może być otwarty przy użyciu routerLink
dla wewnętrznej ścieżki Angulara lub href
dla zewnętrznego adresu URL.
- Musimy sprawdzić czy URL zawiera
http
lubhttps
aby rozróżnić wewnętrzny i zewnętrzny URL routerLink
może wymagać dodatkowych query parametrówJeśli link jest zewnętrzny, czy chcemy go otworzyć w nowej karcie używająchref
itarget="_blank"
- Wiemy też, że jeśli pojawi się
target="_blank"
, powinniśmy ustawićrel="noopener noreferrer"
na taga
dla bezpieczeństwa (https://stackoverflow.com/a/50709760/3375906)
Oto pełna implementacja shared-button
, która obsługuje <<a>
.
A tak wygląda skorzystanie z naszego przycisku w kodzie
Implementacja przycisku może mieć alternatywne Inputy, z którymi pracowałeś np. isTargetBlank
zamiast target
lub redirectURL
zamiast normalnego href
.
Poniżej widzimy wyrenderowany komponent w DOM. Nadal istnieje element shared-button
, wewnątrz którego znajduje się element <a>
.
Ponadto, jeśli nie znasz szczegółów implementacji, może to łatwo skończyć się sytuacją, w której renderujesz cały przycisk wewnątrz tagu <a>, aby uzyskać to samo zachowanie aplikacji.
1 2 3 4 5 6 7 8 9 10 11 |
<a href="https://trungk18.com/" target="_blank"> <shared-button [buttonContent]="readmoreTmpl" [buttonTheme]="'primary'" > <ng-template #readmoreTmpl> Readmore ↗ </ng-template> </shared-button> </a> |
W tym przypadku strukturą DOM będzie a > shared-button > button
, a ponieważ zarówno a
jak i button
są focusowalne, naciśnięcie Tab spowoduje złapanie focusu na każdym z nich.
Na zrzucie ekranu widać, że po wciśnięciu Tab najpierw łapiemy focus na a
(pojawia się domyślny niebieski kontur), a po ponownym kliknięciu widoczny jest nasz niestandardowy kontur wynikający z focusu na wewnętrznym elemencie button
.
To podejście nie będzie dobrze skalowalne
We wszystkich trzech przykładach złożoność implementacji rośnie wraz z dodawaniem kolejnych funkjconalności. Naszym celem cały czas było tylko i wyłącznie to, by zastosować pewne klasy dla button
i a
, dzięki którym uzyskamy odpowiedni wygląd elementów.
Za pomocą kodu próbowaliśmy zduplikować funkcjonalności natywnych elementów, bo te były ukryte przez nasz customowy komponent ‘shared-button’ (przez co nie mogliśmy uzyskać do nich dostępu w inny sposób).
Globalne atrybuty
Ponieważ ukrywamy nasz element button
wewnątrz komponentu shared-button
, jeśli chcemy wspierać nowe atrybuty przycisku, będziemy musieli za każdym razem wprowadzić nowy @Input
dla naszego shared-button
.
Każdy element HTML taki jak button
będzie również pochodził z listą globalnych atrybutów, które musi wspierać.
Zawiera ona wszystkie atrybuty ARIA służące do uczynienia naszej aplikacji bardziej dostępną, co obejmuje około 50+ dodatkowych atrybutów.
Jeśli więc nasz komponent shared-button nagle musi obsługiwać więcej atrybutów, to może to się skończyć w następujący sposób:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
@Input() ariaHidden: boolean; @Input() ariaPlaceholder: string; @Input() ariaPressed: string; @Input() ariaReadonly: string; @Input() ariaRequired: string; @Input() ariaSelected: string; @Input() ariaValueText: string; @Input() ariaControls: string; @Input() ariaDescribedBy: string; @Input() ariaDescription: string; @Input() ariaFlowTo: string; @Input() ariaLabelledBy: string; // and another 100 more Inputs ? |
Augmenting native elements
W przewodniku accessability na angular.io jest jedna mała sekcja, która wspomina o podejściu Augmenting native elements
.
Native HTML elements capture several standard interaction patterns that are important to accessibility. When authoring Angular components, you should re-use these native elements directly when possible, rather than re-implementing well-supported behaviors.
For example, instead of creating a custom element for a new variety of button, create a component that uses an attribute selector with a native
<button>
element. This most commonly applies to<button>
and<a>
, but can be used with many other types of element.
W Angularu, rozszerzanie (ang. augmenting) elementów natywnych
to tworzenie komponentu, który używa selektora atrybutów
do rozszerzenia natywnego elementu <button>
.
Oto jak wygląda kod dla augmenting native elements
:
- Selektor: nie używamy niestandardowego znacznika komponentu, zamiast tego łączymy
button[shared-button]
ia[shared-button]
, ponieważ nie chcemy stosować tego komponentu dla innych natywnych elementów takich jakdiv
. - Template: użycie wyłącznie content-projection, ponieważ chcemy wyrenderować wszystko co jest pomiędzy
button
luba
. - bezpośrednie powiązanie między klasą a elementem natywnym uzyskamy za pomocą
HostBinding
.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
@Component({ + selector: 'button[shared-button], a[shared-button]', + template: ` <ng-content></ng-content> `, changeDetection: ChangeDetectionStrategy.OnPush, styleUrls: ['./button-v2.component.scss'], encapsulation: ViewEncapsulation.None, }) export class ButtonV2Component { + @HostBinding('class') get rdButtonClass(): string { const classes = ['button', `btn-${this.buttonTheme}`]; return classes.filter(Boolean).join(' '); } @Input() buttonTheme: ButtonTheme = 'secondary'; } |
Ponowne zaimplementowanie trzech przykładów przy użyciu podejścia opartego na rozszerzaniu elementów natywnych
Pokazuję kod obok siebie, abyśmy mogli mieć lepsze porównanie. Góra to stare podejście wykorzystujące niestandardowy shared-button
, a dół wykorzystuje nasze nowe podejście z selektorem atrybutowym.
Custom button #1
Custom button #2
Custom button #3
Zauważ, że we wszystkich trzech przykładach:
- Elementem wyrenderowanym w DOM będzie tylko
button
luba
, bez sztucznego i zbędnegowrappera. - Kiedy używamy selektora, mamy dostęp do
button
ia
bezpośrednio, co eliminuje potrzebę przekazywania dodatkowych atrybutów. Jedynym@Input
, który akceptujemy od tej pory jestbuttonTheme
.
Dlaczego to działa?
Selektor Angularowej dyrektywy przyjmuje:
element-name
: selektor po nazwie elementu..class
: selektor po klasie.[attribute]
: selektor po nazwie atrybutu.[attribute=value]
: selektor po nazwie i wartości atrybutu.:not(sub_selector)
: selektor z negacją.selector1, selector2
:selektor z alternatywą (pasuje zarówno do “selector1” jak i do “selector2”),.
W naszym kodzie używamy button[shared-button], a[shared-button]
, który jest kombinacją selektorów element-name
i [attribute]
.
Korzyści
- Znane (natywne) API!
- Znaczya poprawa accessability!
- Prostsza implementacja!
Wiele popularnych bibliotek UI wykorzystuje podejście rozszerzania elementów natywnych
Angular Material
https://github.com/angular/components/blob/main/src/material/button/button.ts#L40
1 2 3 4 5 6 7 8 9 10 11 12 |
@Component({ selector: ` button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button] `, templateUrl: 'button.html', inputs: MAT_BUTTON_INPUTS, exportAs: 'matButton', encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, }) export class MatButton extends MatButtonBase { |
NG-ZORRO
Zauważ tutaj, że ng-zorro rozbudowuje szablon, aby dostarczyć również ikonę ładowania, a nie tylko zwykły ng-content
.
1 2 3 4 5 6 7 8 9 10 11 |
@Component({ selector: 'button[nz-button], a[nz-button]', exportAs: 'nzButton', changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, template: ` + <i nz-icon nzType="loading" *ngIf="nzLoading"></i> <ng-content></ng-content> `, }) export class NzButtonComponent implements OnDestroy, OnChanges, |
Co jeszcze można osiągnąć z augmentacją elementów natywnych?
Angular Material
https://github.com/angular/components/blob/main/src/material/table/table.ts#L41
1 2 3 4 5 6 7 8 9 10 |
@Component({ selector: 'mat-table, table[mat-table]', exportAs: 'matTable', template: CDK_TABLE_TEMPLATE, providers: [ {provide: CdkTable, useExisting: MatTable}, {provide: CDK_TABLE, useExisting: MatTable}, changeDetection: ChangeDetectionStrategy.Default, }) export class MatTable<T> extends CdkTable<T> implements OnInit { |
https://github.com/angular/components/blob/main/src/material/tabs/tab-nav-bar/tab-nav-bar.ts#L307
1 2 3 4 5 6 7 8 |
@Component({ selector: '[mat-tab-nav-bar]', exportAs: 'matTabNavBar, matTabNav', templateUrl: 'tab-nav-bar.html', encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.Default, }) export class MatTabNav extends _MatTabNavBase |
Czy nigdy nie powinniśmy zastępować komponentów natywnych komponentami customowymi?
Nie. Potrzebujemy customowych komponentów!
Jednakże, kiedy tworzysz nowy komponent, powinieneś zadać sobie pytanie
Czy mogę rozszerzyć istniejący element?
Dodaj komentarz