Wróć do strony głównej
Angular

Angular rozszerzanie elementów natywnych

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.

  1. Kod źródłowy i slajdy
  2. Tworzenie niestandardowego komponentu przycisku
    2.1. Custom button #1
    2.2. Custom button #2
    2.3. Create a custom button #3
  3. Globalne atrybuty
  4. 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.

Tworzenie niestandardowego komponentu przycisku

Stworzymy niestandardowy komponent przycisku z trzema wariantami

  1. Przycisk z prostym tekstem
  2. Przycisk z tekstem i ikoną
  3. 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.

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:

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.

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). 

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 lub https 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ąc href i target="_blank"
  • Wiemy też, że jeśli pojawi się target="_blank", powinniśmy ustawić rel="noopener noreferrer" na tag a 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.

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:

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.

Z <[https://angular.io/guide/accessibility#augmenting-native-elements](https://angular.io/guide/accessibility#augmenting-native-elements)

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] i a[shared-button], ponieważ nie chcemy stosować tego komponentu dla innych natywnych elementów takich jak div.
  • Template: użycie wyłącznie content-projection, ponieważ chcemy wyrenderować wszystko co jest pomiędzy button lub  a.
  • bezpośrednie powiązanie między klasą a elementem natywnym uzyskamy za pomocą  HostBinding.

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 lub a, bez sztucznego i zbędnegowrappera.
  • Kiedy używamy selektora, mamy dostęp do button i a bezpośrednio, co eliminuje potrzebę przekazywania dodatkowych atrybutów. Jedynym @Input, który akceptujemy od tej pory jest buttonTheme.

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

NG-ZORRO

https://github.com/NG-ZORRO/ng-zorro-antd/blob/d54b3b4cc44b6e9404a2de1e75ece5c3928ec453/components/button/button.component.ts#L40

Zauważ tutaj, że ng-zorro rozbudowuje szablon, aby dostarczyć również ikonę ładowania, a nie tylko zwykły ng-content.

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

https://github.com/angular/components/blob/main/src/material/tabs/tab-nav-bar/tab-nav-bar.ts#L307

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?

O autorze

Trung Vo

Zapisz się do naszego newslettera. Bądź na bieżąco z najnowszymi trendami, poradami, meetupami i stań się częścią społeczności Angulara w Polsce. Rynek pracy docenia członków społeczności.

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *