Crossroad Development

two roads crossed to make an X

Leveraging Web Components in Astro

2024-05-28

Going into the Capstone class for my degree, we were asked to make three websites for a non fictional non profit joining together two existing organization’s websites. Probably the most interactive part of this project was the requirement that we create a weather widget using third party APIs.

To lay a little groundwork for this project, I decided to use Astro because most of the project was going to be a static site, and I generally appreciate the component system it uses, very similar to Vue’s single file components. On top of that, I have been having a lot of success using Daisy UI, which is built on Tailwind. Using these systems and mimicking the high definition wireframes was the bulk of the work of this project, but I wanted to give a special amount of focus for this weather widget.

Having had experience working with OpenWeatherAPI for our Python class the previous semester, that was an obvious choice for the third party weather API, but a prerequisite for using the API is location data, in latitude and longitude. This poses the first issue, finding the user’s location. I went with the most unintrusive way, which is to use IP lookup API with Geoapify which returns the location data based on the user’s public IP. Going with this approach takes less user interaction, but is less accurate compared to GPS.

For this last semester, I had to do a lot of wireframing projects before actually building them, and using the open source tool Penpot made that fairly easy. There are a hand full of add-ons that are also open source like the icon pack from Lucide which I thought had a much cleaner and simple look compared to the icons from OpenWeatherAPI, so I just exported the SVGs for about six different icons, and wrote the appropriate switch statement to set the innerHTML of the icon element to the correct SVG markup, and then I had a basic component that would display the skeleton loading animation from Daisy UI, make the two async fetch calls to the APIs consecutively, and finally update the UI.

The only problem is the design requirements for this project require the weather widget to display in a top banner for mobile and a sidebar for desktop. There are a few approaches to this problem like morphing the banner into the sidebar, or duplicating the component. I think semantically, duplicating the component is the best method, and using Tailwind’s breakpoint system, e.g. md:hidden lg:block, facilitates very rapid prototyping. The problem I ran into was I used ids to select the elements in the script portion of that component. I could have at that point reworked the code to select a classname, then loop through the classnames and make the changes to all of them. That did not seem right though, like what is the point of making a component and confining all the logic to then use a hacky solution to update every instance of that widget.

So, I bring the problem to the Astro Discord, and I am presented with an option within a few hours that could meet my needs. Just make it a web component! I thought for sure I would need something like Lit to effectively use web components, but honestly they are pretty straightforward and useful out of the box. Afterall, it is just a class in JavaScript. I’ve used classes enough before, at least enough to know how the “this” keyword works, and that every class needs a constructor method. That was about all I needed to make a web component, just make the class extend htmlElement, added some query selectors to define the slots inside the component, and a single updating method. That updating method was also a fun challenge because you don’t necessarily want that to run as soon as it can, you would like it to run after weatherData is available. Easy enough though, if that variable is undefined set a 200ms timeout and recursively call the updating method.

---

---
<style>
    .lucide.sun{
        stroke: #000000 !important;
        fill: #bde0fe !important;
    }
</style>
<weather-astro>
    <div class="skeleton wW w-full h-full text-center">
        <div class="flex flex-col lg:flex-row justify-between lg:justify-around items-center">
            <div class="w-6/12 lg:w-4/12 m-1 weatherIcon" id="weatherIcon"></div>
            <div>
                <h3 class="text-xl lg:text-2xl weatherDesc" id="weatherDesc"></h3>
                <h2 class="text-lg lg:text-xl weatherTemp" id="weatherTemp"></h2>
                <h2 class="text-lg lg:text-xl weatherLoc" id="weatherLoc"></h2> 
            </div>
            
        </div>

    </div>
</weather-astro>


<script>
    let weatherData;

    var requestOptions = {
        method: 'GET',
    };

    async function getWeather(location){
        console.log(location);
        let lat = location.location.latitude;
        let lon = location.location.longitude;

        console.log(lat+' '+lon);

        let url = 'https://api.openweathermap.org/data/2.5/weather?lat='+lat+'&lon='+lon+'&units=imperial&appid=yourAPIKEY';

        const response = await fetch(url);
        const weather = await response.json();

        console.log(weather);
        //updateWeather(weather);
        weatherData = weather;
    }

    fetch("https://api.geoapify.com/v1/ipinfo?&apiKey=yourAPIKEY", requestOptions)
      .then(response => response.json())
      .then(result => getWeather(result))
      .catch(error => console.log('error', error));

    class Weather extends HTMLElement{
        icon;
        desc;
        temp;
        loc;
        wW; 
        constructor(){
            super();

            this.icon = this.querySelector('.weatherIcon');
            this.desc = this.querySelector('.weatherDesc');
            this.temp = this.querySelector('.weatherTemp');
            this.loc = this.querySelector('.weatherLoc');
            this.wW = this.querySelector('.wW');

            this.updateW();
        }
        updateW(){
            if(weatherData){
                switch (parseInt(weatherData.weather[0].icon)){
                    case 1:
                        this.icon.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="#bde0fe" stroke="#000000" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-sun"><circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/></svg>`;
                        break;
                    case 2:
                        this.icon.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="#bde0fe" stroke="#000000" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-cloud-sun"><path d="M12 2v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="M20 12h2"/><path d="m19.07 4.93-1.41 1.41"/><path d="M15.947 12.65a4 4 0 0 0-5.925-4.128"/><path d="M13 22H7a5 5 0 1 1 4.9-6H13a3 3 0 0 1 0 6Z"/></svg>`;
                        break;
                    case 3:
                        this.icon.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="#bde0fe" stroke="#000000" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-cloud"><path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z"/></svg>`;
                        break;
                    case 4:
                        this.icon.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-cloudy"><path d="M17.5 21H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z"/><path d="M22 10a3 3 0 0 0-3-3h-2.207a5.502 5.502 0 0 0-10.702.5"/></svg>`;
                        break;
                    case 9:
                        this.icon.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="#bde0fe" stroke="#000000" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-cloud-rain"><path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242"/><path d="M16 14v6"/><path d="M8 14v6"/><path d="M12 16v6"/></svg>`;
                        break;
                    case 10:
                        this.icon.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="#bde0fe" stroke="#000000" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-cloud-sun-rain"><path d="M12 2v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="M20 12h2"/><path d="m19.07 4.93-1.41 1.41"/><path d="M15.947 12.65a4 4 0 0 0-5.925-4.128"/><path d="M3 20a5 5 0 1 1 8.9-4H13a3 3 0 0 1 2 5.24"/><path d="M11 20v2"/><path d="M7 19v2"/></svg>`;
                        break;
                    case 11:
                        this.icon.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="#bde0fe" stroke="#000000" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-cloud-lightning"><path d="M6 16.326A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 .5 8.973"/><path d="m13 12-3 5h4l-3 5"/></svg>`;
                        break;
                    case 13:
                        this.icon.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="#bde0fe" stroke="#000000" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-snowflake"><line x1="2" x2="22" y1="12" y2="12"/><line x1="12" x2="12" y1="2" y2="22"/><path d="m20 16-4-4 4-4"/><path d="m4 8 4 4-4 4"/><path d="m16 4-4 4-4-4"/><path d="m8 20 4-4 4 4"/></svg>`;
                        break;
                    case 50:
                        this.icon.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="#bde0fe" stroke="#000000" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-waves"><path d="M2 6c.6.5 1.2 1 2.5 1C7 7 7 5 9.5 5c2.6 0 2.4 2 5 2 2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1"/><path d="M2 12c.6.5 1.2 1 2.5 1 2.5 0 2.5-2 5-2 2.6 0 2.4 2 5 2 2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1"/><path d="M2 18c.6.5 1.2 1 2.5 1 2.5 0 2.5-2 5-2 2.6 0 2.4 2 5 2 2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1"/></svg>`;
                        break;

                default: 
                console.log("error loading weather data, this should never occur");
                }
                
                this.temp.innerHTML = weatherData.main.temp+"&deg;&nbsp;F";
                this.desc.innerHTML = weatherData.weather[0].main;
                this.loc.innerHTML = weatherData.name;
                this.wW.classList.remove('skeleton');
                return;
            }
            else{
                setTimeout(()=>this.updateW(), 2000);
            }
        }


    }
    
    customElements.define('weather-astro', Weather)
</script>

Learning this bit of knowledge led me to learning about View Transitions in Astro. Much like web components, it was something I was interested in, but didn’t have a reason or opportunity to explore until these projects. This is a great new feature mostly in Chromium based browsers that does a nice fading page transition instead of a full rerender, when in the same domain. This allows for a great developer experience using semantic URLs and directory structures, which also improves SEO, with the user experience of a single page app, a cohesive experience. Implementing in Astro is also really easy, and involves creating a shared head component which is just a great place to load your google fonts, pass in props for page title and descriptions, and the single thing to make view transitions work the <ViewTransitions /> tag. Then, the way I understand it, you can use directives like transition:persist like I did in my weather widget element to keep the script from running every time the user navigates to a different page. After using the network analyzer in Chrome Dev Tools, that appears to be the case, and should even work to reduce loading the same images multiple times as well.

Overall, I’m just impressed, the developer experience and the community for Astro has been top notch. Personally, I also have enjoyed working with Tailwind and Daisy UI for quickly iterating through design concepts.

Comments