Post contents
Whew! That last chapter was a doozy. Let's slow things down a bit for this chapter: Short and sweet.
Let's think back to the "Dynamic HTML" and "Intro to Components" chapters, where we were building our File
and FileList
components:
const File = ({ href , fileName , isSelected , onSelected , isFolder }) => { const [ inputDate , setInputDate ] = useState ( new Date ()); // ... return ( < button onClick = {onSelected} style = { isSelected ? { backgroundColor: "blue" , color: "white" } : { backgroundColor: "white" , color: "blue" } } > {fileName} {isFolder ? < span >Type: Folder</ span > : < span >Type: File</ span >} { ! isFolder && < FileDate inputDate = {inputDate} />} </ button > ); }; const FileList = () => { // ... return ( // ... < ul > {filesArray. map (( file , i ) => ( < li key = {file.id}> {( ! onlyShowFiles || ! file.isFolder) && ( < File isSelected = {selectedIndex === i} onSelected = {() => onSelected (i)} fileName = {file.fileName} href = {file.href} isFolder = {file.isFolder} /> )} </ li > ))} </ ul > // ... ); };
React Transparent Files Before - StackBlitz
Edit import { createRoot } from "react-dom/client" ; import { useState, useEffect, useMemo } from "react" ; const FileDate = ({ inputDate }) => { const dateStr = useMemo (() => formatDate (inputDate), [inputDate]); const labelText = useMemo (() => formatReadableDate (inputDate), [inputDate]); return < span aria-label = {labelText}>{dateStr}</ span >; }; const File = ({ href , fileName , isSelected , onSelected , isFolder }) => { const [ inputDate , setInputDate ] = useState ( new Date ()); useEffect (() => { // Check if it's a new day every 10 minutes const timeout = setTimeout ( () => { const newDate = new Date (); if (inputDate. getDate () === newDate. getDate ()) return ; setInputDate (newDate); }, 10 * 60 * 1000 , ); return () => clearTimeout (timeout); }, [inputDate]); return ( < button onClick = {onSelected} style = { isSelected ? { backgroundColor: "blue" , color: "white" } : { backgroundColor: "white" , color: "blue" } } > {fileName} {isFolder ? < span >Type: Folder</ span > : < span >Type: File</ span >} { ! isFolder && < FileDate inputDate = {inputDate} />} </ button > ); }; const filesArray = [ { fileName: "File one" , href: "/file/file_one" , isFolder: false , id: 1 , }, { fileName: "File two" , href: "/file/file_two" , isFolder: false , id: 2 , }, { fileName: "File three" , href: "/file/file_three" , isFolder: false , id: 3 , }, { fileName: "Folder one" , href: "/file/folder_one/" , isFolder: true , id: 4 , }, { fileName: "Folder two" , href: "/file/folder_two/" , isFolder: true , id: 5 , }, ]; const FileList = () => { const [ selectedIndex , setSelectedIndex ] = useState ( - 1 ); const onSelected = ( idx ) => { if (selectedIndex === idx) { setSelectedIndex ( - 1 ); return ; } setSelectedIndex (idx); }; const [ onlyShowFiles , setOnlyShowFiles ] = useState ( false ); const toggleOnlyShow = () => setOnlyShowFiles ( ! onlyShowFiles); return ( < div > < button onClick = {toggleOnlyShow}>Only show files</ button > < ul > {filesArray. map (( file , i ) => ( < li key = {file.id}> {( ! onlyShowFiles || ! file.isFolder) && ( < File isSelected = {selectedIndex === i} onSelected = {() => onSelected (i)} fileName = {file.fileName} href = {file.href} isFolder = {file.isFolder} /> )} </ li > ))} </ ul > </ div > ); }; createRoot (document. getElementById ( "root" )). render (< FileList />); function formatDate ( inputDate ) { // Month starts at 0, annoyingly const month = inputDate. getMonth () + 1 ; const date = inputDate. getDate (); const year = inputDate. getFullYear (); return month + "/" + date + "/" + year; } function formatReadableDate ( inputDate ) { const months = [ "January" , "February" , "March" , "April" , "May" , "June" , "July" , "August" , "September" , "October" , "November" , "December" , ]; const monthStr = months[inputDate. getMonth ()]; const dateSuffixStr = dateSuffix (inputDate. getDate ()); const yearNum = inputDate. getFullYear (); return monthStr + " " + dateSuffixStr + "," + yearNum; } function dateSuffix ( dayNumber ) { const lastDigit = dayNumber % 10 ; if (lastDigit == 1 && dayNumber != 11 ) { return dayNumber + "st" ; } if (lastDigit == 2 && dayNumber != 12 ) { return dayNumber + "nd" ; } if (lastDigit == 3 && dayNumber != 13 ) { return dayNumber + "rd" ; } return dayNumber + "th" ; }
@ Component ({ selector: "file-item" , imports: [FileDateComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: ` < button (click) = "selected.emit()" [style] = " isSelected() ? 'background-color: blue; color: white' : 'background-color: white; color: blue' " > {{ fileName () }} @if ( isFolder ()) { < span >Type: Folder</ span > } @else { < span >Type: File</ span > } @if ( ! isFolder ()) { < file-date [inputDate] = "inputDate()" /> } </ button > ` , }) class FileComponent { fileName = input. required < string >(); href = input. required < string >(); isSelected = input ( false ); isFolder = input ( false ); selected = output (); inputDate = signal ( new Date ()); // ... } @ Component ({ selector: "file-list" , imports: [FileComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: ` <!-- ... --> < ul > @for ( file of filesArray; track file.id; let i = $index) { < li > @if ( onlyShowFiles () ? ! file.isFolder : true ) { < file-item (selected) = "onSelected(i)" [isSelected] = "selectedIndex() === i" [fileName] = "file.fileName" [href] = "file.href" [isFolder] = "file.isFolder" /> } </ li > } </ ul > <!-- ... --> ` , }) class FileListComponent { // ... }
Angular Transparent Files Before - StackBlitz
Edit import { bootstrapApplication } from "@angular/platform-browser" ; import { Component, signal, input, computed, output, effect, provideExperimentalZonelessChangeDetection, ChangeDetectionStrategy, } from "@angular/core" ; @ Component ({ selector: "file-date" , changeDetection: ChangeDetectionStrategy.OnPush, template: ` <span [attr.aria-label]="labelText()"> {{ dateStr() }} </span> ` , }) class FileDateComponent { inputDate = input. required < Date >(); dateStr = computed (() => formatDate ( this . inputDate ())); labelText = computed (() => formatReadableDate ( this . inputDate ())); } @ Component ({ selector: "file-item" , imports: [FileDateComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: ` <button (click)="selected.emit()" [style]=" isSelected() ? 'background-color: blue; color: white' : 'background-color: white; color: blue' " > {{ fileName() }} @if (isFolder()) { <span>Type: Folder</span> } @else { <span>Type: File</span> } @if (!isFolder()) { <file-date [inputDate]="inputDate()" /> } </button> ` , }) class FileComponent { fileName = input. required < string >(); href = input. required < string >(); isSelected = input ( false ); isFolder = input ( false ); selected = output (); inputDate = signal ( new Date ()); constructor () { effect (( onCleanup ) => { // Check if it's a new day every 10 minutes const interval = setInterval ( () => { const newDate = new Date (); if ( this . inputDate (). getDate () === newDate. getDate ()) return ; this .inputDate. set (newDate); }, 10 * 60 * 1000 , ); onCleanup (() => { clearInterval (interval); }); }); } } @ Component ({ selector: "file-list" , imports: [FileComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: ` <div> <button (click)="toggleOnlyShow()">Only show files</button> <ul> @for (file of filesArray; track file.id; let i = $index) { <li> @if (onlyShowFiles() ? !file.isFolder : true) { <file-item (selected)="onSelected(i)" [isSelected]="selectedIndex() === i" [fileName]="file.fileName" [href]="file.href" [isFolder]="file.isFolder" /> } </li> } </ul> </div> ` , }) class FileListComponent { selectedIndex = signal ( - 1 ); onSelected ( idx : number ) { if ( this . selectedIndex () === idx) { this .selectedIndex. set ( - 1 ); return ; } this .selectedIndex. set (idx); } onlyShowFiles = signal ( false ); toggleOnlyShow () { this .onlyShowFiles. set ( ! this . onlyShowFiles ()); } filesArray : File [] = [ { fileName: "File one" , href: "/file/file_one" , isFolder: false , id: 1 , }, { fileName: "File two" , href: "/file/file_two" , isFolder: false , id: 2 , }, { fileName: "File three" , href: "/file/file_three" , isFolder: false , id: 3 , }, { fileName: "Folder one" , href: "/file/folder_one/" , isFolder: true , id: 4 , }, { fileName: "Folder two" , href: "/file/folder_two/" , isFolder: true , id: 5 , }, ]; } interface File { fileName : string ; href : string ; isFolder : boolean ; id : number ; } function formatDate ( inputDate : Date ) { // Month starts at 0, annoyingly const monthNum = inputDate. getMonth () + 1 ; const dateNum = inputDate. getDate (); const yearNum = inputDate. getFullYear (); return monthNum + "/" + dateNum + "/" + yearNum; } function formatReadableDate ( inputDate : Date ) { const months = [ "January" , "February" , "March" , "April" , "May" , "June" , "July" , "August" , "September" , "October" , "November" , "December" , ]; const monthStr = months[inputDate. getMonth ()]; const dateSuffixStr = dateSuffix (inputDate. getDate ()); const yearNum = inputDate. getFullYear (); return monthStr + " " + dateSuffixStr + "," + yearNum; } function dateSuffix ( dayNumber : number ) { const lastDigit = dayNumber % 10 ; if (lastDigit == 1 && dayNumber != 11 ) { return dayNumber + "st" ; } if (lastDigit == 2 && dayNumber != 12 ) { return dayNumber + "nd" ; } if (lastDigit == 3 && dayNumber != 13 ) { return dayNumber + "rd" ; } return dayNumber + "th" ; } bootstrapApplication (FileListComponent, { providers: [ provideExperimentalZonelessChangeDetection ()], });
<!-- File.vue --> < script setup > import { ref, onMounted, onUnmounted } from "vue" ; import FileDate from "./FileDate.vue" ; const props = defineProps ([ "isSelected" , "isFolder" , "fileName" , "href" ]); const emit = defineEmits ([ "selected" ]); const inputDate = ref ( new Date ()); // ... </ script > < template > < button v-on:click = "emit('selected')" :style = " isSelected ? 'background-color: blue; color: white' : 'background-color: white; color: blue' " > {{ fileName }} < span v-if = "isFolder" >Type: Folder</ span > < span v-else >Type: File</ span > < FileDate v-if = "!isFolder" :inputDate = "inputDate" /> </ button > </ template >
<!-- FileList.vue --> < script setup > // ... </ script > < template > <!-- ... --> < ul > < li v-for = "(file, i) in filesArray" :key = "file.id" > < File v-if = "onlyShowFiles ? !file.isFolder : true" @selected = "onSelected(i)" :isSelected = "selectedIndex === i" :fileName = "file.fileName" :href = "file.href" :isFolder = "file.isFolder" /> </ li > </ ul > <!-- ... --> </ template >
Vue Transparent Files Before - StackBlitz
Edit <!-- FileList.vue --> < script setup > import { ref } from "vue" ; import File from "./File.vue" ; const filesArray = [ { fileName: "File one" , href: "/file/file_one" , isFolder: false , id: 1 , }, { fileName: "File two" , href: "/file/file_two" , isFolder: false , id: 2 , }, { fileName: "File three" , href: "/file/file_three" , isFolder: false , id: 3 , }, { fileName: "Folder one" , href: "/file/folder_one/" , isFolder: true , id: 4 , }, { fileName: "Folder two" , href: "/file/folder_two/" , isFolder: true , id: 5 , }, ]; const selectedIndex = ref ( - 1 ); function onSelected ( idx ) { if (selectedIndex.value === idx) { selectedIndex.value = - 1 ; return ; } selectedIndex.value = idx; } const onlyShowFiles = ref ( false ); function toggleOnlyShow () { onlyShowFiles.value = ! onlyShowFiles.value; } </ script > < template > < div > < button @click = "toggleOnlyShow()" >Only show files</ button > < ul > < li v-for = "(file, i) in filesArray" :key = "file.id" > < File v-if = "onlyShowFiles ? !file.isFolder : true" @selected = "onSelected(i)" :isSelected = "selectedIndex === i" :fileName = "file.fileName" :href = "file.href" :isFolder = "file.isFolder" /> </ li > </ ul > </ div > </ template >
While this theoretically works , there's a significant problem with it. Let's take a look at what the HTML looks like when rendering with onlyShowFiles=true
and the following filesArray
:
[ { fileName: "File one" , href: "/file/file_one" , isFolder: false , id: 1 , }, { fileName: "Folder one" , href: "" , isFolder: true , id: 2 , }, ];
Because our conditional statement is on the li
when rendered to the DOM, it might look something like this:
<!-- ... --> < ul > < li > <!-- File Component --> < button >...</ button > </ li > < li ></ li > </ ul > <!-- ... -->
While this might not seem like a big problem at first, the fact that there's an empty li
in the middle of our ul
introduces three issues:
It will leave an empty space created by any styling you have applied to the li
.
Any assistive technologies, like screen readers , will read out that there's an empty item, which is a confusing behavior for those users.
Any search engines reading data off of your page may incorrectly assume that your list is intentionally empty, thus potentially impacting your ranking on sites.
Solving these issues is where something called "transparent elements" comes into play. See, ideally, what we want to have is something like a tag that renders to nothing .
Consider supporting Donating any amount will help towards further development of the Framework Field Guide.
Sponsor my work
This means that if we could instead generate something like the following pseudo-syntax in framework code:
< ul > < nothing > < li > < button >...</ button > </ li > </ nothing > < nothing ></ nothing > </ ul >
We could render this into the DOM itself:
< ul > < li > < button >...</ button > </ li > </ ul >
Luckily for us, each of the three frameworks provides a method for doing so, simply with a different syntax. Let's see how each framework does so:
In React, we use something called a "Fragment" in place of the nothing
component.
import { Fragment } from "react" ; // ... < ul > {filesArray. map (( file , i ) => ( < Fragment key = {file.id}> {( ! onlyShowFiles || ! file.isFolder) && ( < li > < File isSelected = {selectedIndex === i} onSelected = {() => onSelected (i)} fileName = {file.fileName} href = {file.href} isFolder = {file.isFolder} /> </ li > )} </ Fragment > ))} </ ul >;
React Transparent Files After - StackBlitz
Edit import { createRoot } from "react-dom/client" ; import { useState, useEffect, useMemo, Fragment } from "react" ; const FileDate = ({ inputDate }) => { const dateStr = useMemo (() => formatDate (inputDate), [inputDate]); const labelText = useMemo (() => formatReadableDate (inputDate), [inputDate]); return < span aria-label = {labelText}>{dateStr}</ span >; }; const File = ({ href , fileName , isSelected , onSelected , isFolder }) => { const [ inputDate , setInputDate ] = useState ( new Date ()); useEffect (() => { // Check if it's a new day every 10 minutes const timeout = setTimeout ( () => { const newDate = new Date (); if (inputDate. getDate () === newDate. getDate ()) return ; setInputDate (newDate); }, 10 * 60 * 1000 , ); return () => clearTimeout (timeout); }, [inputDate]); return ( < button onClick = {onSelected} style = { isSelected ? { backgroundColor: "blue" , color: "white" } : { backgroundColor: "white" , color: "blue" } } > {fileName} {isFolder ? < span >Type: Folder</ span > : < span >Type: File</ span >} { ! isFolder && < FileDate inputDate = {inputDate} />} </ button > ); }; const filesArray = [ { fileName: "File one" , href: "/file/file_one" , isFolder: false , id: 1 , }, { fileName: "File two" , href: "/file/file_two" , isFolder: false , id: 2 , }, { fileName: "File three" , href: "/file/file_three" , isFolder: false , id: 3 , }, { fileName: "Folder one" , href: "/file/folder_one/" , isFolder: true , id: 4 , }, { fileName: "Folder two" , href: "/file/folder_two/" , isFolder: true , id: 5 , }, ]; const FileList = () => { const [ selectedIndex , setSelectedIndex ] = useState ( - 1 ); const onSelected = ( idx ) => { if (selectedIndex === idx) { setSelectedIndex ( - 1 ); return ; } setSelectedIndex (idx); }; const [ onlyShowFiles , setOnlyShowFiles ] = useState ( false ); const toggleOnlyShow = () => setOnlyShowFiles ( ! onlyShowFiles); return ( < div > < button onClick = {toggleOnlyShow}>Only show files</ button > < ul > {filesArray. map (( file , i ) => ( < Fragment key = {file.id}> {( ! onlyShowFiles || ! file.isFolder) && ( < li > < File isSelected = {selectedIndex === i} onSelected = {() => onSelected (i)} fileName = {file.fileName} href = {file.href} isFolder = {file.isFolder} /> </ li > )} </ Fragment > ))} </ ul > </ div > ); }; createRoot (document. getElementById ( "root" )). render (< FileList />); function formatDate ( inputDate ) { // Month starts at 0, annoyingly const month = inputDate. getMonth () + 1 ; const date = inputDate. getDate (); const year = inputDate. getFullYear (); return month + "/" + date + "/" + year; } function formatReadableDate ( inputDate ) { const months = [ "January" , "February" , "March" , "April" , "May" , "June" , "July" , "August" , "September" , "October" , "November" , "December" , ]; const monthStr = months[inputDate. getMonth ()]; const dateSuffixStr = dateSuffix (inputDate. getDate ()); const yearNum = inputDate. getFullYear (); return monthStr + " " + dateSuffixStr + "," + yearNum; } function dateSuffix ( dayNumber ) { const lastDigit = dayNumber % 10 ; if (lastDigit == 1 && dayNumber != 11 ) { return dayNumber + "st" ; } if (lastDigit == 2 && dayNumber != 12 ) { return dayNumber + "nd" ; } if (lastDigit == 3 && dayNumber != 13 ) { return dayNumber + "rd" ; } return dayNumber + "th" ; }
Fragment
Alternative Syntax
Fragment
also has an alternative syntax in JSX. Instead of <Fragment></Fragment>
, you can simply do <></>
. This shorthand removes the need for the import and makes the above code sample read like this:
< ul > {filesArray. map (( file , i ) => ( <> {( ! onlyShowFiles || ! file.isFolder) && ( < li > < File /> </ li > )} </> ))} </ ul >
You may notice that <>
syntax for Fragment
does not have a key
associated with it. This is because the <>
syntax does not allow you to have props associated with it.
However, this means that your loop will still misbehave and add performance overhead as a penalty for not including key
(as we discussed in the "Dynamic HTML" chapter ). For this reason, when inside a map
loop, you'll want to use Fragment
with a key
property associated with it.
Angular's version of the nothing
element is the ng-container
element.
< ul > @for ( file of filesArray; let i = $index; track file.id) { < ng-container > @if ( onlyShowFiles () ? ! file.isFolder : true ) { < li > < file-item (selected) = "onSelected(i)" [isSelected] = "selectedIndex() === i" [fileName] = "file.fileName" [href] = "file.href" [isFolder] = "file.isFolder" /> </ li > } </ ng-container > } </ ul >
Angular Transparent Files After - StackBlitz
Edit import { bootstrapApplication } from "@angular/platform-browser" ; import { Component, OnInit, OnDestroy, signal, computed, effect, output, input, provideExperimentalZonelessChangeDetection, ChangeDetectionStrategy, } from "@angular/core" ; @ Component ({ selector: "file-date" , changeDetection: ChangeDetectionStrategy.OnPush, template: ` <span [attr.aria-label]="labelText()"> {{ dateStr() }} </span> ` , }) class FileDateComponent { inputDate = input. required < Date >(); dateStr = computed (() => formatDate ( this . inputDate ())); labelText = computed (() => formatReadableDate ( this . inputDate ())); } @ Component ({ selector: "file-item" , imports: [FileDateComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: ` <button (click)="selected.emit()" [style]=" isSelected() ? 'background-color: blue; color: white' : 'background-color: white; color: blue' " > {{ fileName() }} @if (isFolder()) { <span>Type: Folder</span> } @else { <span>Type: File</span> } @if (!isFolder()) { <file-date [inputDate]="inputDate()" /> } </button> ` , }) class FileComponent { fileName = input. required < string >(); href = input. required < string >(); isSelected = input ( false ); isFolder = input ( false ); selected = output (); inputDate = signal ( new Date ()); constructor () { effect (( onCleanup ) => { // Check if it's a new day every 10 minutes const interval = setInterval ( () => { const newDate = new Date (); if ( this . inputDate (). getDate () === newDate. getDate ()) return ; this .inputDate. set (newDate); }, 10 * 60 * 1000 , ); onCleanup (() => { clearInterval (interval); }); }); } } @ Component ({ selector: "file-list" , imports: [FileComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: ` <div> <button (click)="toggleOnlyShow()">Only show files</button> <ul> @for (file of filesArray; track file.id; let i = $index) { @if (onlyShowFiles() ? !file.isFolder : true) { <li> <file-item (selected)="onSelected(i)" [isSelected]="selectedIndex() === i" [fileName]="file.fileName" [href]="file.href" [isFolder]="file.isFolder" /> </li> } } </ul> </div> ` , }) class FileListComponent { selectedIndex = signal ( - 1 ); onSelected ( idx : number ) { if ( this . selectedIndex () === idx) { this .selectedIndex. set ( - 1 ); return ; } this .selectedIndex. set (idx); } onlyShowFiles = signal ( false ); toggleOnlyShow () { this .onlyShowFiles. set ( ! this . onlyShowFiles ()); } filesArray : File [] = [ { fileName: "File one" , href: "/file/file_one" , isFolder: false , id: 1 , }, { fileName: "File two" , href: "/file/file_two" , isFolder: false , id: 2 , }, { fileName: "File three" , href: "/file/file_three" , isFolder: false , id: 3 , }, { fileName: "Folder one" , href: "/file/folder_one/" , isFolder: true , id: 4 , }, { fileName: "Folder two" , href: "/file/folder_two/" , isFolder: true , id: 5 , }, ]; } interface File { fileName : string ; href : string ; isFolder : boolean ; id : number ; } function formatDate ( inputDate : Date ) { // Month starts at 0, annoyingly const monthNum = inputDate. getMonth () + 1 ; const dateNum = inputDate. getDate (); const yearNum = inputDate. getFullYear (); return monthNum + "/" + dateNum + "/" + yearNum; } function formatReadableDate ( inputDate : Date ) { const months = [ "January" , "February" , "March" , "April" , "May" , "June" , "July" , "August" , "September" , "October" , "November" , "December" , ]; const monthStr = months[inputDate. getMonth ()]; const dateSuffixStr = dateSuffix (inputDate. getDate ()); const yearNum = inputDate. getFullYear (); return monthStr + " " + dateSuffixStr + "," + yearNum; } function dateSuffix ( dayNumber : number ) { const lastDigit = dayNumber % 10 ; if (lastDigit == 1 && dayNumber != 11 ) { return dayNumber + "st" ; } if (lastDigit == 2 && dayNumber != 12 ) { return dayNumber + "nd" ; } if (lastDigit == 3 && dayNumber != 13 ) { return dayNumber + "rd" ; } return dayNumber + "th" ; } bootstrapApplication (FileListComponent, { providers: [ provideExperimentalZonelessChangeDetection ()], });
However, unlike React and Vue; we don't need to use ng-container
in this example. Angular allows us to have multiple elements at the root of any control flow block, so we can remove the ng-container
and have the following:
< ul > @for ( file of filesArray; let i = $index; track file.id) { @if ( onlyShowFiles () ? ! file.isFolder : true ) { < li > < file-item (selected) = "onSelected(i)" [isSelected] = "selectedIndex() === i" [fileName] = "file.fileName" [href] = "file.href" [isFolder] = "file.isFolder" /> </ li > } } </ ul >
This has the same effect as the previous example, but with less code. We'll continue to use ng-container
in this chapter to keep the examples consistent with other frameworks, but there's little need for it in most modern Angular codebases.
To render out something akin to a nothing
element, we can use a template
element with a v-for
or v-if
associated with it.
< template > < ul > < template v-for = " (file, i) of filesArray " : key = " file.id " > < li v-if = "onlyShowFiles ? !file.isFolder : true" > < File @selected = "onSelected(i)" :isSelected = "selectedIndex === i" :fileName = "file.fileName" :href = "file.href" :isFolder = "file.isFolder" /> </ li > </ template > </ ul > </ template >
Vue Transparent Files After - StackBlitz
Edit <!-- FileList.vue --> < script setup > import { ref } from "vue" ; import File from "./File.vue" ; const filesArray = [ { fileName: "File one" , href: "/file/file_one" , isFolder: false , id: 1 , }, { fileName: "File two" , href: "/file/file_two" , isFolder: false , id: 2 , }, { fileName: "File three" , href: "/file/file_three" , isFolder: false , id: 3 , }, { fileName: "Folder one" , href: "/file/folder_one/" , isFolder: true , id: 4 , }, { fileName: "Folder two" , href: "/file/folder_two/" , isFolder: true , id: 5 , }, ]; const selectedIndex = ref ( - 1 ); function onSelected ( idx ) { if (selectedIndex.value === idx) { selectedIndex.value = - 1 ; return ; } selectedIndex.value = idx; } const onlyShowFiles = ref ( false ); function toggleOnlyShow () { onlyShowFiles.value = ! onlyShowFiles.value; } </ script > < template > < div > < button @click = "toggleOnlyShow()" >Only show files</ button > < ul > < template v-for = " (file, i) of filesArray " : key = " file.id " > < li v-if = "onlyShowFiles ? !file.isFolder : true" > < File @selected = "onSelected(i)" :isSelected = "selectedIndex === i" :fileName = "file.fileName" :href = "file.href" :isFolder = "file.isFolder" /> </ li > </ template > </ ul > </ div > </ template >
Stacking Transparent Elements
Just as a quick note, not only can these nothing
elements be used once, but they can be stacked back-to-back to do... Well, nothing!
Here are some code samples that render out the following:
< p >Test</ p >
<> <> <> < p >Test</ p > </> </> </>
React Stacked Transparent - StackBlitz
Edit import { createRoot } from "react-dom/client" ; const App = () => { return ( <> <> <> < p >Test</ p > </> </> </> ); }; createRoot (document. getElementById ( "root" )). render (< App />);
< ng-container > < ng-container > < ng-container > < p >Test</ p > </ ng-container > </ ng-container > </ ng-container >
Angular Stacked Transparent - StackBlitz
Edit import { bootstrapApplication } from "@angular/platform-browser" ; import { Component, provideExperimentalZonelessChangeDetection, ChangeDetectionStrategy, } from "@angular/core" ; @ Component ({ selector: "app-root" , changeDetection: ChangeDetectionStrategy.OnPush, template: ` <ng-container> <ng-container> <ng-container> <p>Test</p> </ng-container> </ng-container> </ng-container> ` , }) class AppComponent {} bootstrapApplication (AppComponent, { providers: [ provideExperimentalZonelessChangeDetection ()], });
While the other frameworks have a more 1:1 mapping between our pseudo-syntax nothing
, Vue has a slightly different approach due to its reuse of the existing HTML <template>
tag .
By default, if you render a template
in Vue in any other place besides the root, it will render nothing to the screen:
< template > < template > < p >Test</ p > </ template > </ template >
It's worth mentioning that even if it shows nothing on screen, the template
element is still in the DOM itself, waiting to be used in other ways. While explaining "why" an HTML template
element renders nothing by default is outside the scope of this book, it is expected behavior.
However, if you add a v-for
, v-if
, or a v-slot
(we'll touch on what a v-slot
is in our "Accessing Children" chapter ), it will remove the <template>
and only render out the children.
This means that both:
< template > < template v-if = " true " > < p >Test</ p > </ template > </ template >
And:
< template > < template v-if = " true " > < template v-if = " true " > < template v-if = " true " > < p >Test</ p > </ template > </ template > </ template > </ template >
Will both render out to the following HTML:
< p >Test</ p >
Vue Stacked Transparent - StackBlitz
Edit < template > < template v-if = " true " > < template v-if = " true " > < template v-if = " true " > < p >Test</ p > </ template > </ template > </ template > </ template >
Of course, these rules don't apply to the root-level template
, that acts as a container for our template code. It's a bit confusing at first, but makes sense when you practice more.
Challenge
Now that we understand how to render a transparent element (transparent to the DOM, anyway), let's build out an example where this would be useful.
Namely, let's assume that we want to build out a bar of buttons with a gap between them:
To do this with HTML, we might have the following template and styling:
< div style = " display: 'inline-flex', gap: 1rem; " > < button >Delete</ button > < button >Copy</ button > < button >Favorite</ button > < button >Settings</ button > </ div >
However, what if we wanted to only display the first three buttons:
Only when a file is selected?
Let's build this out using our favorite frameworks:
const FileActionButtons = ({ onDelete , onCopy , onFavorite }) => { return ( < div > < button onClick = {onDelete}>Delete</ button > < button onClick = {onCopy}>Copy</ button > < button onClick = {onFavorite}>Favorite</ button > </ div > ); }; const ButtonBar = ({ onSettings, onDelete, onCopy, onFavorite, fileSelected, }) => { return ( < div style = {{ display: "flex" , gap: "1rem" , }} > {fileSelected && ( < FileActionButtons onDelete = {onDelete} onCopy = {onCopy} onFavorite = {onFavorite} /> )} < button onClick = {onSettings}>Settings</ button > </ div > ); };
@ Component ({ selector: "file-action-buttons" , changeDetection: ChangeDetectionStrategy.OnPush, template: ` < div > < button (click) = "delete.emit()" >Delete</ button > < button (click) = "copy.emit()" >Copy</ button > < button (click) = "favorite.emit()" >Favorite</ button > </ div > ` , }) class FileActionButtonsComponent { delete = output (); copy = output (); favorite = output (); } @ Component ({ selector: "button-bar" , changeDetection: ChangeDetectionStrategy.OnPush, imports: [FileActionButtonsComponent], template: ` < div style = "display: flex; gap: 1rem" > @if ( fileSelected ()) { < file-action-buttons (delete) = "delete.emit()" (copy) = "copy.emit()" (favorite) = "favorite.emit()" /> } < button (click) = "settings.emit()" >Settings</ button > </ div > ` , }) class ButtonBarComponent { fileSelected = input. required < boolean >(); delete = output (); copy = output (); favorite = output (); settings = output (); }
<!-- FileActionButtons.vue --> < script setup > const emit = defineEmits ([ "delete" , "copy" , "favorite" ]); </ script > < template > < div > < button @click = "emit('delete')" >Delete</ button > < button @click = "emit('copy')" >Copy</ button > < button @click = "emit('favorite')" >Favorite</ button > </ div > </ template >
<!-- ButtonBar.vue --> < script setup > import FileActionButtons from "./FileActionButtons.vue" ; const props = defineProps ([ "fileSelected" ]); const emit = defineEmits ([ "delete" , "copy" , "favorite" , "settings" ]); </ script > < template > < div style = "display: flex; gap: 1rem" > < FileActionButtons v-if = "props.fileSelected" @delete = "emit('delete')" @copy = "emit('copy')" @favorite = "emit('favorite')" /> < button @click = "emit('settings')" >Settings</ button > </ div > </ template >
Oh no! The rendered output isn't as we expected!
That's because when we used a div
for our FileActionButtons
component, it bypassed the gap
property of CSS. To fix this, we can use our handy dandy nothing
element:
// FileActionButtons <> < button onClick = {onDelete}>Delete</ button > < button onClick = {onCopy}>Copy</ button > < button onClick = {onFavorite}>Favorite</ button > </>
Final code output
React Dynamic Challenge - StackBlitz
Edit import { createRoot } from "react-dom/client" ; const FileActionButtons = ({ onDelete , onCopy , onFavorite }) => { return ( <> < button onClick = {onDelete}>Delete</ button > < button onClick = {onCopy}>Copy</ button > < button onClick = {onFavorite}>Favorite</ button > </> ); }; const ButtonBar = ({ onSettings, onDelete, onCopy, onFavorite, fileSelected, }) => { return ( < div style = {{ display: "flex" , gap: "1rem" , }} > {fileSelected && ( < FileActionButtons onDelete = {onDelete} onCopy = {onCopy} onFavorite = {onFavorite} /> )} < button onClick = {onSettings}>Settings</ button > </ div > ); }; const App = () => { function alertMe ( str ) { alert ( "You have pressed on " + str); } return ( < ButtonBar fileSelected = { true } onSettings = {() => alertMe ( "settings" )} onDelete = {() => alertMe ( "delete" )} onCopy = {() => alertMe ( "copy" )} onFavorite = {() => alertMe ( "favorite" )} /> ); }; createRoot (document. getElementById ( "root" )). render (< App />);
@ Component ({ selector: "file-action-buttons" , changeDetection: ChangeDetectionStrategy.OnPush, template: ` < ng-container > < button (click) = "delete.emit()" >Delete</ button > < button (click) = "copy.emit()" >Copy</ button > < button (click) = "favorite.emit()" >Favorite</ button > </ ng-container > ` , styles: [ ` :host { display : contents ; } ` , ], }) class FileActionButtonsComponent { // ... }
We can even simplify this by removing the ng-container
, since Angular supports multiple elements at the root of the component template.
@ Component ({ selector: "file-action-buttons" , changeDetection: ChangeDetectionStrategy.OnPush, template: ` < button (click) = "delete.emit()" >Delete</ button > < button (click) = "copy.emit()" >Copy</ button > < button (click) = "favorite.emit()" >Favorite</ button > ` , styles: [ ` :host { display : contents ; } ` , ], }) class FileActionButtonsComponent { // ... }
Unlike the other frameworks we're talking about, Angular's components add in an HTML element in the DOM.
For example, here, our rendered markup looks like:
< div style = "display: flex; gap: 1rem;" > < file-action-buttons > < button >Delete</ button > < button >Copy</ button > < button >Favorite</ button > </ file-action-buttons > < button >Settings</ button > </ div >
This causes our gap
root to not apply to the inner buttons. To sidestep this, we need to use styles
and tell our host
component to treat the button
s container as if it doesn't exist.
Final code output
Angular Dynamic Challenge - StackBlitz
Edit import { bootstrapApplication } from "@angular/platform-browser" ; import { Component, input, output, provideExperimentalZonelessChangeDetection, ChangeDetectionStrategy, } from "@angular/core" ; @ Component ({ selector: "file-action-buttons" , changeDetection: ChangeDetectionStrategy.OnPush, template: ` <button (click)="delete.emit()">Delete</button> <button (click)="copy.emit()">Copy</button> <button (click)="favorite.emit()">Favorite</button> ` , styles: [ ` :host { display: contents; } ` , ], }) class FileActionButtonsComponent { delete = output (); copy = output (); favorite = output (); } @ Component ({ selector: "button-bar" , imports: [FileActionButtonsComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: ` <div style="display: flex; gap: 1rem"> @if (fileSelected()) { <file-action-buttons (delete)="delete.emit()" (copy)="copy.emit()" (favorite)="favorite.emit()" /> } <button (click)="settings.emit()">Settings</button> </div> ` , }) class ButtonBarComponent { fileSelected = input. required < boolean >(); delete = output (); copy = output (); favorite = output (); settings = output (); } @ Component ({ selector: "app-root" , imports: [ButtonBarComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: ` <button-bar [fileSelected]="true" (delete)="alertMe('delete')" (copy)="alertMe('copy')" (favorite)="alertMe('favorite')" (settings)="alertMe('settings')" /> ` , }) class AppComponent { alertMe ( str : string ) { alert ( "You have pressed on " + str); } } bootstrapApplication (AppComponent, { providers: [ provideExperimentalZonelessChangeDetection ()], });
Because Vue's root <template>
can support multiple elements without the need for v-if
, v-for
, or v-slot
, we can do the following:
<!-- FileActionButtons.vue --> < template > < button @click = "emit('delete')" >Delete</ button > < button @click = "emit('copy')" >Copy</ button > < button @click = "emit('favorite')" >Favorite</ button > </ template > <!-- ... -->
Final code output
Vue Dynamic Challenge - StackBlitz
Edit
src/FileActionButtons.vue<!-- FileActionButtons.vue --> < script setup > const emit = defineEmits ([ "delete" , "copy" , "favorite" ]); </ script > < template > < button @click = "emit('delete')" >Delete</ button > < button @click = "emit('copy')" >Copy</ button > < button @click = "emit('favorite')" >Favorite</ button > </ template >