> All in One 586: November 2024

Ads

Saturday, November 30, 2024

Partly Cloudy today!



With a high of F and a low of 23F. Currently, it's 32F and Mostly Cloudy outside.

Current wind speeds: 9 from the Southwest

Pollen: 0

Sunrise: November 30, 2024 at 07:51PM

Sunset: December 1, 2024 at 05:29AM

UV index: 0

Humidity: 62%

via https://ift.tt/RFSxZK4

December 1, 2024 at 10:02AM

Friday, November 29, 2024

Clear today!



With a high of F and a low of 22F. Currently, it's 27F and Clear outside.

Current wind speeds: 10 from the Southwest

Pollen: 0

Sunrise: November 29, 2024 at 07:51PM

Sunset: November 30, 2024 at 05:29AM

UV index: 0

Humidity: 67%

via https://ift.tt/tWYpEfP

November 30, 2024 at 10:02AM

Thursday, November 28, 2024

Mostly Clear today!



With a high of F and a low of 20F. Currently, it's 23F and Clear outside.

Current wind speeds: 4 from the Southeast

Pollen: 0

Sunrise: November 28, 2024 at 07:50PM

Sunset: November 29, 2024 at 05:29AM

UV index: 0

Humidity: 73%

via https://ift.tt/VjJZnkw

November 29, 2024 at 10:02AM

Wednesday, November 27, 2024

Clear today!



With a high of F and a low of 20F. Currently, it's 26F and Clear outside.

Current wind speeds: 8 from the Northwest

Pollen: 0

Sunrise: November 27, 2024 at 07:48PM

Sunset: November 28, 2024 at 05:30AM

UV index: 0

Humidity: 84%

via https://ift.tt/94uI0yT

November 28, 2024 at 10:02AM

WordPress Multi-Multisite: A Case Study

The mission: Provide a dashboard within the WordPress admin area for browsing Google Analytics data for all your blogs.

The catch? You’ve got about 900 live blogs, spread across about 25 WordPress multisite instances. Some instances have just one blog, others have as many as 250. In other words, what you need is to compress a data set that normally takes a very long time to compile into a single user-friendly screen.

The implementation details are entirely up to you, but the final result should look like this Figma comp:

Design courtesy of the incomparable Brian Biddle.

I want to walk you through my approach and some of the interesting challenges I faced coming up with it, as well as the occasional nitty-gritty detail in between. I’ll cover topics like the WordPress REST API, choosing between a JavaScript or PHP approach, rate/time limits in production web environments, security, custom database design — and even a touch of AI. But first, a little orientation.

Let’s define some terms

We’re about to cover a lot of ground, so it’s worth spending a couple of moments reviewing some key terms we’ll be using throughout this post.

What is WordPress multisite?

WordPress Multisite is a feature of WordPress core — no plugins required — whereby you can run multiple blogs (or websites, or stores, or what have you) from a single WordPress installation. All the blogs share the same WordPress core files, wp-content folder, and MySQL database. However, each blog gets its own folder within wp-content/uploads for its uploaded media, and its own set of database tables for its posts, categories, options, etc. Users can be members of some or all blogs within the multisite installation.

What is WordPress multi-multisite?

It’s just a nickname for managing multiple instances of WordPress multisite. It can get messy to have different customers share one multisite instance, so I prefer to break it up so that each customer has their own multisite, but they can have many blogs within their multisite.

So that’s different from a “Network of Networks”?

It’s apparently possible to run multiple instances of WordPress multisite against the same WordPress core installation. I’ve never looked into this, but I recall hearing about it over the years. I’ve heard the term “Network of Networks” and I like it, but that is not the scenario I’m covering in this article.

Why do you keep saying “blogs”? Do people still blog?

You betcha! And people read them, too. You’re reading one right now. Hence, the need for a robust analytics solution. But this article could just as easily be about any sort of WordPress site. I happen to be dealing with blogs, and the word “blog” is a concise way to express “a subsite within a WordPress multisite instance”.

One more thing: In this article, I’ll use the term dashboard site to refer to the site from which I observe the compiled analytics data. I’ll use the term client sites to refer to the 25 multisites I pull data from.

My implementation

My strategy was to write one WordPress plugin that is installed on all 25 client sites, as well as on the dashboard site. The plugin serves two purposes:

  • Expose data at API endpoints of the client sites
  • Scrape the data from the client sites from the dashboard site, cache it in the database, and display it in a dashboard.

The WordPress REST API is the Backbone

The WordPress REST API is my favorite part of WordPress. Out of the box, WordPress exposes default WordPress stuff like posts, authors, comments, media files, etc., via the WordPress REST API. You can see an example of this by navigating to /wp-json from any WordPress site, including CSS-Tricks. Here’s the REST API root for the WordPress Developer Resources site:

The root URL for the WordPress REST API exposes structured JSON data, such as this example from the WordPress Developer Resources website.

What’s so great about this? WordPress ships with everything developers need to extend the WordPress REST API and publish custom endpoints. Exposing data via an API endpoint is a fantastic way to share it with other websites that need to consume it, and that’s exactly what I did:

Open the code

<?php

[...]

function register(\WP_REST_Server $server) {
  $endpoints = $this->get();

  foreach ($endpoints as $endpoint_slug => $endpoint) {
    register_rest_route(
      $endpoint['namespace'],
      $endpoint['route'],
      $endpoint['args']
    );
  }
}

function get() {

  $version = 'v1';

  return array(
      
    'empty_db' => array(
      'namespace' => 'LXB_DBA/' . $version,
      'route'     => '/empty_db',
      'args'      => array(
        'methods' => array( 'DELETE' ),
        'callback' => array($this, 'empty_db_cb'),
        'permission_callback' => array( $this, 'is_admin' ),
      ),
    ),

    'get_blogs' => array(
      'namespace' => 'LXB_DBA/' . $version,
      'route'     => '/get_blogs',
      'args'      => array(
        'methods' => array('GET', 'OPTIONS'),
        'callback' => array($this, 'get_blogs_cb'),
        'permission_callback' => array($this, 'is_dba'),
      ),
    ),

    'insert_blogs' => array(
      'namespace' => 'LXB_DBA/' . $version,
      'route'     => '/insert_blogs',
      'args'      => array(
        'methods' => array( 'POST' ),
        'callback' => array($this, 'insert_blogs_cb'),
        'permission_callback' => array( $this, 'is_admin' ),
      ),
    ),

    'get_blogs_from_db' => array(
      'namespace' => 'LXB_DBA/' . $version,
      'route'     => '/get_blogs_from_db',
      'args'      => array(
        'methods' => array( 'GET' ),
        'callback' => array($this, 'get_blogs_from_db_cb'),
        'permission_callback' => array($this, 'is_admin'),
      ),
    ),  

    'get_blog_details' => array(
      'namespace' => 'LXB_DBA/' . $version,
      'route'     => '/get_blog_details',
      'args'      => array(
        'methods' => array( 'GET' ),
        'callback' => array($this, 'get_blog_details_cb'),
        'permission_callback' => array($this, 'is_dba'),
      ),
    ),   

    'update_blogs' => array(
      'namespace' => 'LXB_DBA/' . $version,
      'route'     => '/update_blogs',
      'args'      => array(
        'methods' => array( 'PATCH' ),
        'callback' => array($this, 'update_blogs_cb'),
        'permission_callback' => array($this, 'is_admin'),
      ),
    ),     

  );
}

We don’t need to get into every endpoint’s details, but I want to highlight one thing. First, I provided a function that returns all my endpoints in an array. Next, I wrote a function to loop through the array and register each array member as a WordPress REST API endpoint. Rather than doing both steps in one function, this decoupling allows me to easily retrieve the array of endpoints in other parts of my plugin to do other interesting things with them, such as exposing them to JavaScript. More on that shortly.

Once registered, the custom API endpoints are observable in an ordinary web browser like in the example above, or via purpose-built tools for API work, such as Postman:

JSON output.

PHP vs. JavaScript

I tend to prefer writing applications in PHP whenever possible, as opposed to JavaScript, and executing logic on the server, as nature intended, rather than in the browser. So, what would that look like on this project?

  • On the dashboard site, upon some event, such as the user clicking a “refresh data” button or perhaps a cron job, the server would make an HTTP request to each of the 25 multisite installs.
  • Each multisite install would query all of its blogs and consolidate its analytics data into one response per multisite.

Unfortunately, this strategy falls apart for a couple of reasons:

  • PHP operates synchronously, meaning you wait for one line of code to execute before moving to the next. This means that we’d be waiting for all 25 multisites to respond in series. That’s sub-optimal.
  • My production environment has a max execution limit of 60 seconds, and some of my multisites contain hundreds of blogs. Querying their analytics data takes a second or two per blog.

Damn. I had no choice but to swallow hard and commit to writing the application logic in JavaScript. Not my favorite, but an eerily elegant solution for this case:

  • Due to the asynchronous nature of JavaScript, it pings all 25 Multisites at once.
  • The endpoint on each Multisite returns a list of all the blogs on that Multisite.
  • The JavaScript compiles that list of blogs and (sort of) pings all 900 at once.
  • All 900 blogs take about one-to-two seconds to respond concurrently.

Holy cow, it just went from this:

( 1 second per Multisite * 25 installs ) + ( 1 second per blog * 900 blogs ) = roughly 925 seconds to scrape all the data.

To this:

1 second for all the Multisites at once + 1 second for all 900 blogs at once = roughly 2 seconds to scrape all the data.

That is, in theory. In practice, two factors enforce a delay:

  1. Browsers have a limit as to how many concurrent HTTP requests they will allow, both per domain and regardless of domain. I’m having trouble finding documentation on what those limits are. Based on observing the network panel in Chrome while working on this, I’d say it’s about 50-100.
  2. Web hosts have a limit on how many requests they can handle within a given period, both per IP address and overall. I was frequently getting a “429; Too Many Requests” response from my production environment, so I introduced a delay of 150 milliseconds between requests. They still operate concurrently, it’s just that they’re forced to wait 150ms per blog. Maybe “stagger” is a better word than “wait” in this context:
Open the code
async function getBlogsDetails(blogs) {
  let promises = [];

  // Iterate and set timeouts to stagger requests by 100ms each
  blogs.forEach((blog, index) => {
    if (typeof blog.url === 'undefined') {
      return;
    }

    let id = blog.id;
    const url = blog.url + '/' + blogDetailsEnpointPath + '?uncache=' + getRandomInt();

    // Create a promise that resolves after 150ms delay per blog index
    const delayedPromise = new Promise(resolve => {
      setTimeout(async () => {
        try {
          const blogResult = await fetchBlogDetails(url, id);
                
          if( typeof blogResult.urls == 'undefined' ) {
            console.error( url, id, blogResult );

          } else if( ! blogResult.urls ) {
            console.error( blogResult );
                
                
          } else if( blogResult.urls.length == 0 ) {
            console.error( blogResult );
                
          } else {
            console.log( blogResult );
          }
                
          resolve(blogResult);
        } catch (error) {
          console.error(`Error fetching details for blog ID ${id}:`, error);
          resolve(null); // Resolve with null to handle errors gracefully
      }
    }, index * 150); // Offset each request by 100ms
  });

  promises.push(delayedPromise);
});

  // Wait for all requests to complete
  const blogsResults = await Promise.all(promises);

  // Filter out any null results in case of caught errors
  return blogsResults.filter(result => result !== null);
}

With these limitations factored in, I found that it takes about 170 seconds to scrape all 900 blogs. This is acceptable because I cache the results, meaning the user only has to wait once at the start of each work session.

The result of all this madness — this incredible barrage of Ajax calls, is just plain fun to watch:

PHP and JavaScript: Connecting the dots

I registered my endpoints in PHP and called them in JavaScript. Merging these two worlds is often an annoying and bug-prone part of any project. To make it as easy as possible, I use wp_localize_script():

<?php

[...]

class Enqueue {

  function __construct() {
    add_action( 'admin_enqueue_scripts', array( $this, 'lexblog_network_analytics_script' ), 10 );
    add_action( 'admin_enqueue_scripts', array( $this, 'lexblog_network_analytics_localize' ), 11 );
  }

  function lexblog_network_analytics_script() {
    wp_register_script( 'lexblog_network_analytics_script', LXB_DBA_URL . '/js/lexblog_network_analytics.js', array( 'jquery', 'jquery-ui-autocomplete' ), false, false );
  }

  function lexblog_network_analytics_localize() {
    $a = new LexblogNetworkAnalytics;
    $data = $a -> get_localization_data();
    $slug = $a -> get_slug();

    wp_localize_script( 'lexblog_network_analytics_script', $slug, $data );

  }

  // etc.              
}

In that script, I’m telling WordPress two things:

  1. Load my JavaScript file.
  2. When you do, take my endpoint URLs, bundle them up as JSON, and inject them into the HTML document as a global variable for my JavaScript to read. This is leveraging the point I noted earlier where I took care to provide a convenient function for defining the endpoint URLs, which other functions can then invoke without fear of causing any side effects.

Here’s how that ended up looking:

The JSON and its associated JavaScript file, where I pass information from PHP to JavaScript using wp_localize_script().

Auth: Fort Knox or Sandbox?

We need to talk about authentication. To what degree do these endpoints need to be protected by server-side logic? Although exposing analytics data is not nearly as sensitive as, say, user passwords, I’d prefer to keep things reasonably locked up. Also, since some of these endpoints perform a lot of database queries and Google Analytics API calls, it’d be weird to sit here and be vulnerable to weirdos who might want to overload my database or Google Analytics rate limits.

That’s why I registered an application password on each of the 25 client sites. Using an app password in php is quite simple. You can authenticate the HTTP requests just like any basic authentication scheme.

I’m using JavaScript, so I had to localize them first, as described in the previous section. With that in place, I was able to append these credentials when making an Ajax call:

async function fetchBlogsOfInstall(url, id) {
  let install = lexblog_network_analytics.installs[id];
  let pw = install.pw;
  let user = install.user;

  // Create a Basic Auth token
  let token = btoa(`${user}:${pw}`);
  let auth = {
      'Authorization': `Basic ${token}`
  };

  try {
    let data = await $.ajax({
        url: url,
        method: 'GET',
        dataType: 'json',
        headers: auth
    });

    return data;

  } catch (error) {
    console.error('Request failed:', error);
    return [];
  }
}

That file uses this cool function called btoa() for turning the raw username and password combo into basic authentication.

The part where we say, “Oh Right, CORS.”

Whenever I have a project where Ajax calls are flying around all over the place, working reasonably well in my local environment, I always have a brief moment of panic when I try it on a real website, only to get errors like this:

CORS console error.

Oh. Right. CORS. Most reasonably secure websites do not allow other websites to make arbitrary Ajax requests. In this project, I absolutely do need the Dashboard Site to make many Ajax calls to the 25 client sites, so I have to tell the client sites to allow CORS:

<?php

  // ...

  function __construct() {
  add_action( 'rest_api_init', array( $this, 'maybe_add_cors_headers' ), 10 );
}

function maybe_add_cors_headers() {   
  // Only allow CORS for the endpoints that pertain to this plugin.
  if( $this->is_dba() ) {
      add_filter( 'rest_pre_serve_request', array( $this, 'send_cors_headers' ), 10, 2 );
  }
}

function is_dba() {
  $url = $this->get_current_url();
  $ep_urls = $this->get_endpoint_urls();
  $out = in_array( $url, $ep_urls );
          
  return $out;
          
}

function send_cors_headers( $served, $result ) {

          // Only allow CORS from the dashboard site.
  $dashboard_site_url = $this->get_dashboard_site_url();

  header( "Access-Control-Allow-Origin: $dashboard_site_url" );
  header( 'Access-Control-Allow-Headers: Origin, X-Requested-With, Content-Type, Accept, Authorization' );
  header( 'Access-Control-Allow-Methods: GET, OPTIONS' );
  return $served;
  
}

  [...]

}

You’ll note that I’m following the principle of least privilege by taking steps to only allow CORS where it’s necessary.

Auth, Part 2: I’ve been known to auth myself

I authenticated an Ajax call from the dashboard site to the client sites. I registered some logic on all the client sites to allow the request to pass CORS. But then, back on the dashboard site, I had to get that response from the browser to the server.

The answer, again, was to make an Ajax call to the WordPress REST API endpoint for storing the data. But since this was an actual database write, not merely a read, it was more important than ever to authenticate. I did this by requiring that the current user be logged into WordPress and possess sufficient privileges. But how would the browser know about this?

In PHP, when registering our endpoints, we provide a permissions callback to make sure the current user is an admin:

<?php

// ...

function get() {
  $version = 'v1';
  return array(

    'update_blogs' => array(
      'namespace' => 'LXB_DBA/' . $version,
      'route'     => '/update_blogs',
      'args'      => array(
        'methods' => array( 'PATCH' ),
        'callback' => array( $this, 'update_blogs_cb' ),
        'permission_callback' => array( $this, 'is_admin' ),
        ),
      ),
      // ...
    );           
  }
                      
function is_admin() {
    $out = current_user_can( 'update_core' );
    return $out;
}

JavaScript can use this — it’s able to identify the current user — because, once again, that data is localized. The current user is represented by their nonce:

async function insertBlog( data ) {
    
  let url = lexblog_network_analytics.endpoint_urls.insert_blog;

  try {
    await $.ajax({
      url: url,
      method: 'POST',
      dataType: 'json',
      data: data,
      headers: {
        'X-WP-Nonce': getNonce()
      }
    });
  } catch (error) {
    console.error('Failed to store blogs:', error);
  }
}

function getNonce() {
  if( typeof wpApiSettings.nonce == 'undefined' ) { return false; }
  return wpApiSettings.nonce;
}

The wpApiSettings.nonce global variable is automatically present in all WordPress admin screens. I didn’t have to localize that. WordPress core did it for me.

Cache is King

Compressing the Google Analytics data from 900 domains into a three-minute loading .gif is decent, but it would be totally unacceptable to have to wait for that long multiple times per work session. Therefore I cache the results of all 25 client sites in the database of the dashboard site.

I’ve written before about using the WordPress Transients API for caching data, and I could have used it on this project. However, something about the tremendous volume of data and the complexity implied within the Figma design made me consider a different approach. I like the saying, “The wider the base, the higher the peak,” and it applies here. Given that the user needs to query and sort the data by date, author, and metadata, I think stashing everything into a single database cell — which is what a transient is — would feel a little claustrophobic. Instead, I dialed up E.F. Codd and used a relational database model via custom tables:

In the Dashboard Site, I created seven custom database tables, including one relational table, to cache the data from the 25 client sites, as shown in the image.

It’s been years since I’ve paged through Larry Ullman’s career-defining (as in, my career) books on database design, but I came into this project with a general idea of what a good architecture would look like. As for the specific details — things like column types — I foresaw a lot of Stack Overflow time in my future. Fortunately, LLMs love MySQL and I was able to scaffold out my requirements using DocBlocks and let Sam Altman fill in the blanks:

Open the code
<?php 

/**
* Provides the SQL code for creating the Blogs table.  It has columns for:
* - ID: The ID for the blog.  This should just autoincrement and is the primary key.
* - name: The name of the blog.  Required.
* - slug: A machine-friendly version of the blog name.  Required.
* - url:  The url of the blog.  Required.
* - mapped_domain: The vanity domain name of the blog.  Optional.
* - install: The name of the Multisite install where this blog was scraped from.  Required.
* - registered:  The date on which this blog began publishing posts.  Optional.
* - firm_id:  The ID of the firm that publishes this blog.  This will be used as a foreign key to relate to the Firms table.  Optional.
* - practice_area_id:  The ID of the firm that publishes this blog.  This will be used as a foreign key to relate to the PracticeAreas table.  Optional.
* - amlaw:  Either a 0 or a 1, to indicate if the blog comes from an AmLaw firm.  Required.
* - subscriber_count:  The number of email subscribers for this blog.  Optional.
* - day_view_count:  The number of views for this blog today.  Optional.
* - week_view_count:  The number of views for this blog this week.  Optional.
* - month_view_count:  The number of views for this blog this month.  Optional.
* - year_view_count:  The number of views for this blog this year.  Optional.
* 
* @return string The SQL for generating the blogs table.
*/
function get_blogs_table_sql() {
  $slug = 'blogs';
  $out = "CREATE TABLE {$this->get_prefix()}_$slug (
      id BIGINT NOT NULL AUTO_INCREMENT,
      slug VARCHAR(255) NOT NULL,
      name VARCHAR(255) NOT NULL,
      url VARCHAR(255) NOT NULL UNIQUE, /* adding unique constraint */
      mapped_domain VARCHAR(255) UNIQUE,
      install VARCHAR(255) NOT NULL,
      registered DATE DEFAULT NULL,
      firm_id BIGINT,
      practice_area_id BIGINT,
      amlaw TINYINT NOT NULL,
      subscriber_count BIGINT,
      day_view_count BIGINT,
      week_view_count BIGINT,
      month_view_count BIGINT,
      year_view_count BIGINT,
      PRIMARY KEY (id),
      FOREIGN KEY (firm_id) REFERENCES {$this->get_prefix()}_firms(id),
      FOREIGN KEY (practice_area_id) REFERENCES {$this->get_prefix()}_practice_areas(id)
  ) DEFAULT CHARSET=utf8mb4;";
  return $out;
}

In that file, I quickly wrote a DocBlock for each function, and let the OpenAI playground spit out the SQL. I tested the result and suggested some rigorous type-checking for values that should always be formatted as numbers or dates, but that was the only adjustment I had to make. I think that’s the correct use of AI at this moment: You come in with a strong idea of what the result should be, AI fills in the details, and you debate with it until the details reflect what you mostly already knew.

How it’s going

I’ve implemented most of the user stories now. Certainly enough to release an MVP and begin gathering whatever insights this data might have for us:

Screenshot of the final dashboard which looks similar to the Figma mockups from earlier.
It’s working!

One interesting data point thus far: Although all the blogs are on the topic of legal matters (they are lawyer blogs, after all), blogs that cover topics with a more general appeal seem to drive more traffic. Blogs about the law as it pertains to food, cruise ships, germs, and cannabis, for example. Furthermore, the largest law firms on our network don’t seem to have much of a foothold there. Smaller firms are doing a better job of connecting with a wider audience. I’m positive that other insights will emerge as we work more deeply with this.

Regrets? I’ve had a few.

This project probably would have been a nice opportunity to apply a modern JavaScript framework, or just no framework at all. I like React and I can imagine how cool it would be to have this application be driven by the various changes in state rather than… drumrolla couple thousand lines of jQuery!

I like jQuery’s ajax() method, and I like the jQueryUI autocomplete component. Also, there’s less of a performance concern here than on a public-facing front-end. Since this screen is in the WordPress admin area, I’m not concerned about Google admonishing me for using an extra library. And I’m just faster with jQuery. Use whatever you want.

I also think it would be interesting to put AWS to work here and see what could be done through Lambda functions. Maybe I could get Lambda to make all 25 plus 900 requests concurrently with no worries about browser limitations. Heck, maybe I could get it to cycle through IP addresses and sidestep the 429 rate limit as well.

And what about cron? Cron could do a lot of work for us here. It could compile the data on each of the 25 client sites ahead of time, meaning that the initial three-minute refresh time goes away. Writing an application in cron, initially, I think is fine. Coming back six months later to debug something is another matter. Not my favorite. I might revisit this later on, but for now, the cron-free implementation meets the MVP goal.

I have not provided a line-by-line tutorial here, or even a working repo for you to download, and that level of detail was never my intention. I wanted to share high-level strategy decisions that might be of interest to fellow Multi-Multisite people. Have you faced a similar challenge? I’d love to hear about it in the comments!


WordPress Multi-Multisite: A Case Study originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.



from CSS-Tricks https://ift.tt/aIH3Qd6
via IFTTT

Tuesday, November 26, 2024

Snow Late today!



With a high of F and a low of 30F. Currently, it's 38F and Mostly Cloudy outside.

Current wind speeds: 6 from the Southeast

Pollen: 0

Sunrise: November 26, 2024 at 07:47PM

Sunset: November 27, 2024 at 05:30AM

UV index: 0

Humidity: 64%

via https://ift.tt/vXnAu2P

November 27, 2024 at 10:02AM

Follow Up: We Officially Have a CSS Logo!

As a follow up to the search for a new CSS logo, it looks like we have a winner!

new CSS logo in three colors, rebeccapurple, white, and black

Since our last post, the color shifted away from a vibrant pink to a color with a remarkable history among the CSS community: rebeccapurple

With 400 votes on GitHub, I think the community has chosen well.

Check out Adam’s post on selecting the winner!


Follow Up: We Officially Have a CSS Logo! originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.



from CSS-Tricks https://ift.tt/HbOlNvW
via IFTTT

Monday, November 25, 2024

Mostly Cloudy today!



With a high of F and a low of 26F. Currently, it's 30F and Fair outside.

Current wind speeds: 14 from the Southwest

Pollen: 0

Sunrise: November 25, 2024 at 07:46PM

Sunset: November 26, 2024 at 05:30AM

UV index: 0

Humidity: 79%

via https://ift.tt/N81kcjE

November 26, 2024 at 10:02AM

Alt Text: Not Always Needed

Alt text is one of those things in my muscle memory that pops up anytime I’m working with an image element. The attribute almost writes itself.

<img src="image.jpg" alt="">

Or if you use Emmet, that’s autocompleted for you. Don’t forget the alt text! Use it even if there’s no need for it, as an empty string is simply skipped by screen readers. That’s called “nulling” the alternative text and many screen readers simply announce the image file name. Just be sure it’s truly an empty string because even a space gets picked up by some assistive tech, which causes a screen reader to completely skip the image:

<!-- Not empty -->
<img src="image.jpg" alt=" ">

But wait… are there situations where an image doesn’t need alt text? I tend to agree with Eric that the vast majority of images are more than decorative and need to be described. Your images are probably not decorative and ought to be described with alt text.

Probably is doing a lot of lifting there because not all images are equal when it comes to content and context. Emma Cionca and Tanner Kohler have a fresh study on those situations where you probably don’t need alt. It’s a well-written and researched piece and I’m rounding up some nuggets from it.

What Users Need from Alt Text

It’s the same as what anyone else would need from an image: an easy path to accomplish basic tasks. A product image is a good example of that. Providing a visual smooths the path to purchasing because it’s context about what the item looks like and what to expect when you get it. Not providing an image almost adds friction to the experience if you have to stop and ask customer support basic questions about the size and color of that shirt you want.

So, yes. Describe that image in alt! But maybe “describe” isn’t the best wording because the article moves on to make the next point…

Quit Describing What Images Look Like

The article gets into a common trap that I’m all too guilty of, which is describing an image in a way that I find helpful. Or, as the article says, it’s a lot like I’m telling myself, “I’ll describe it in the alt text so screen-reader users can imagine what they aren’t seeing.”

That’s the wrong way of going about it. Getting back to the example of a product image, the article outlines how a screen reader might approach it:

For example, here’s how a screen-reader user might approach a product page:

  1. Jump between the page headers to get a sense of the page structure.
  2. Explore the details of a specific section with the heading label Product Description.
  3. Encounter an image and wonder “What information that I might have missed elsewhere does this image communicate about the product?

Interesting! Where I might encounter an image and evaluate it based on the text around it, a screen reader is already questioning what content has been missed around it. This passage is one I need to reflect on (emphasis mine):

Most of the time, screen-reader users don’t wonder what images look like. Instead, they want to know their purpose. (Exceptions to this rule might include websites presenting images, such as artwork, purely for visual enjoyment, or users who could previously see and have lost their sight.)

OK, so how in the heck do we know when an image needs describing? It feels so awkward making what’s ultimately a subjective decision. Even so, the article presents three questions to pose to ourselves to determine the best route.

  1. Is the image repetitive? Is the task-related information in the image also found elsewhere on the page?
  2. Is the image referential? Does the page copy directly reference the image?
  3. Is the image efficient? Could alt text help users more efficiently complete a task?

This is the meat of the article, so I’m gonna break those out.

Is the image repetitive?

Repetitive in the sense that the content around it is already doing a bang-up job painting a picture. If the image is already aptly “described” by content, then perhaps it’s possible to get away with nulling the alt attribute.

This is the figure the article uses to make the point (and, yes, I’m alt-ing it):

Showing a caption describing an image located directly above it.

The caption for this image describes exactly what the image communicates. Therefore, any alt text for the image will be redundant and a waste of time for screen-reader users. In this case, the actual alt text was the same as the caption. Coming across the same information twice in a row feels even more confusing and unnecessary.

The happy path:

<img src="image.jpg" alt="">

But check this out this image about informal/semi-formal table setting showing how it is not described by the text around it (and, no, I’m not alt-ing it):

If I was to describe this image, I might get carried away describing the diagram and all the points outlined in the legend. If I can read all of that, then a screen reader should, too, right? Not exactly. I really appreciate the slew of examples provided in the article. A sampling:

  1. Bread plate and butter knife, located in the top left corner. 
  2. Dessert fork, placed horizontally at the top center. 
  3. Dessert spoon, placed horizontally at the top center, below the dessert fork.

That’s way less verbose than I would have gone. Talking about how long (or short) alt ought to be is another topic altogether.

Is the image referential?

The second image I dropped in that last section is a good example of a referential image because I directly referenced it in the content preceding it. I nulled the alt attribute because of that. But what I messed up is not making the image recognizable to screen readers. If the alt attribute is null, then the screen reader skips it. But the screen reader should still know it’s there even if it’s aptly described.

The happy path:

<img src="image.jpg" alt="">

Remember that a screen reader may announce the image’s file name. So maybe use that as an opportunity to both call out the image and briefly describe it. Again, we want the screen reader to announce the image if we make mention of it in the content around it. Simply skipping it may cause more confusion than clarity.

Is the image efficient?

My mind always goes to performance when I see the word efficient pop up in reference to images. But in this context the article means whether or not the image can help visitors efficiently complete a task.

If the image helps complete a task, say purchasing a product, then yes, the image needs alt text. But if the content surrounding it already does the job then we can leave it null (alt="") or skip it (alt=" ") if there’s no mention of it.

Wrapping up

I put a little demo together with some testing results from a few different screen readers to see how all of that shakes out.


Alt Text: Not Always Needed originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.



from CSS-Tricks https://ift.tt/A5xHcib
via IFTTT

Sunday, November 24, 2024

Mostly Cloudy today!



With a high of F and a low of 24F. Currently, it's 28F and Mostly Cloudy outside.

Current wind speeds: 15 from the North

Pollen: 0

Sunrise: November 24, 2024 at 07:45PM

Sunset: November 25, 2024 at 05:31AM

UV index: 0

Humidity: 88%

via https://ift.tt/dTwCvkx

November 25, 2024 at 10:02AM

Saturday, November 23, 2024

Partly Cloudy today!



With a high of F and a low of 32F. Currently, it's 41F and Clear outside.

Current wind speeds: 14 from the Southwest

Pollen: 0

Sunrise: November 23, 2024 at 07:44PM

Sunset: November 24, 2024 at 05:31AM

UV index: 0

Humidity: 47%

via https://ift.tt/kYbzvdo

November 24, 2024 at 10:02AM

Friday, November 22, 2024

Partly Cloudy today!



With a high of F and a low of 30F. Currently, it's 37F and Clear outside.

Current wind speeds: 9 from the Southwest

Pollen: 0

Sunrise: November 22, 2024 at 07:43PM

Sunset: November 23, 2024 at 05:32AM

UV index: 0

Humidity: 57%

via https://ift.tt/K9z1NBU

November 23, 2024 at 10:02AM

Solved by CSS: Donuts Scopes

Thursday, November 21, 2024

Mostly Clear today!



With a high of F and a low of 28F. Currently, it's 34F and Clear outside.

Current wind speeds: 10 from the Southwest

Pollen: 0

Sunrise: November 21, 2024 at 07:42PM

Sunset: November 22, 2024 at 05:32AM

UV index: 0

Humidity: 64%

via https://ift.tt/MoPqK28

November 22, 2024 at 10:02AM

Wednesday, November 20, 2024

Clear today!



With a high of F and a low of 24F. Currently, it's 28F and Clear outside.

Current wind speeds: 9 from the Southwest

Pollen: 0

Sunrise: November 20, 2024 at 07:41PM

Sunset: November 21, 2024 at 05:33AM

UV index: 0

Humidity: 69%

via https://ift.tt/Hi2dWmQ

November 21, 2024 at 10:02AM

Invoker Commands: Additional Ways to Work With Dialog, Popover… and More?

The Popover API and <dialog> element are two of my favorite new platform features. In fact, I recently [wrote a detailed overview of their use cases] and the sorts of things you can do with them, even learning a few tricks in the process that I couldn’t find documented anywhere else.

I’ll admit that one thing that I really dislike about popovers and dialogs is that they could’ve easily been combined into a single API. They cover different use cases (notably, dialogs are typically modal) but are quite similar in practice, and yet their implementations are different.

Well, web browsers are now experimenting with two HTML attributes — technically, they’re called “invoker commands” — that are designed to invoke popovers, dialogs, and further down the line, all kinds of actions without writing JavaScript. Although, if you do reach for JavaScript, the new attributes — command and commandfor — come with some new events that we can listen for.

Invoker commands? I’m sure you have questions, so let’s dive in.

We’re in experimental territory

Before we get into the weeds, we’re dealing with experimental features. To use invoker commands today in November 2024 you’ll need Chrome Canary 134+ with the enable-experimental-web-platform-features flag set to Enabled, Firefox Nightly 135+ with the dom.element.invokers.enabled flag set to true, or Safari Technology Preview with the InvokerAttributesEnabled flag set to true.

I’m optimistic we’ll get baseline coverage for command and commandfor in due time considering how nicely they abstract the kind of work that currently takes a hefty amount of scripting.

Basic command and commandfor usage

First, you’ll need a <button> or a button-esque <input> along the lines of <input type="button"> or <input type="reset">. Next, tack on the command attribute. The command value should be the command name that you want the button to invoke (e.g., show-modal). After that, drop the commandfor attribute in there referencing the dialog or popover you’re targeting by its id.

<button command="show-modal" commandfor="dialogA">Show dialogA</button>

<dialog id="dialogA">...</dialog>

In this example, I have a <button> element with a command attribute set to show-modal and a commandfor attribute set to dialogA, which matches the id of a <dialog> element we’re targeting:

Let’s get into the possible values for these invoker commands and dissect what they’re doing.

Looking closer at the attribute values

The show-modal value is the command that I just showed you in that last example. Specifically, it’s the HTML-invoked equivalent of JavaScript’s showModal() method.

The main benefit is that show-modal enables us to, well… show a modal without reaching directly for JavaScript. Yes, this is almost identical to how HTML-invoked popovers already work with thepopovertarget and popovertargetaction attributes, so it’s cool that the “balance is being redressed” as the Open UI explainer describes it, even more so because you can use the command and commandfor invoker commands for popovers too.

There isn’t a show command to invoke show() for creating non-modal dialogs. I’ve mentioned before that non-modal dialogs are redundant now that we have the Popover API, especially since popovers have ::backdrops and other dialog-like features. My bold prediction is that non-modal dialogs will be quietly phased out over time.

The close command is the HTML-invoked equivalent of JavaScript’s close() method used for closing the dialog. You probably could have guessed that based on the name alone!

<dialog id="dialogA">
  <!-- Close #dialogA -->
  <button command="close" commandfor="dialogA">Close dialogA</button>
</dialog>

The show-popover, hide-popover, and toggle-popover values

<button command="show-popover" commandfor="id">

…invokes showPopover(), and is the same thing as:

<button popovertargetaction="show" popovertarget="id">

Similarly:

<button command="hide-popover" commandfor="id">

…invokes hidePopover(), and is the same thing as:

<button popovertargetaction="hide" popovertarget="id">

Finally:

<button command="toggle-popover" commandfor="id">

…invokes togglePopover(), and is the same thing as:

<button popovertargetaction="toggle" popovertarget="id">
<!--  or <button popovertarget="id">, since ‘toggle’ is the default action anyway. -->

I know all of this can be tough to organize in your mind’s eye, so perhaps a table will help tie things together:

command Invokes popovertargetaction equivalent
show-popover showPopover() show
hide-popover hidePopover() hide
toggle-popover togglePopover() toggle

So… yeah, popovers can already be invoked using HTML attributes, making command and commandfor not all that useful in this context. But like I said, invoker commands also come with some useful JavaScript stuff, so let’s dive into all of that.

Listening to commands with JavaScript

Invoker commands dispatch a command event to the target whenever their source button is clicked on, which we can listen for and work with in JavaScript. This isn’t required for a <dialog> element’s close event, or a popover attribute’s toggle or beforetoggle event, because we can already listen for those, right?

For example, the Dialog API doesn’t dispatch an event when a <dialog> is shown. So, let’s use invoker commands to listen for the command event instead, and then read event.command to take the appropriate action.

// Select all dialogs
const dialogs = document.querySelectorAll("dialog");

// Loop all dialogs
dialogs.forEach(dialog => {

  // Listen for close (as normal)
  dialog.addEventListener("close", () => {
    // Dialog was closed
  });

  // Listen for command
  dialog.addEventListener("command", event => {

    // If command is show-modal
    if (event.command == "show-modal") {
      // Dialog was shown (modally)
    }

    // Another way to listen for close
    else if (event.command == "close") {
      // Dialog was closed
    }

  });
});

So invoker commands give us additional ways to work with dialogs and popovers, and in some scenarios, they’ll be less verbose. In other scenarios though, they’ll be more verbose. Your approach should depend on what you need your dialogs and popovers to do.

For the sake of completeness, here’s an example for popovers, even though it’s largely the same:

// Select all popovers
const popovers = document.querySelectorAll("[popover]");

// Loop all popovers
popovers.forEach(popover => {

  // Listen for command
  popover.addEventListener("command", event => {

    // If command is show-popover
    if (event.command == "show-popover") {
      // Popover was shown
    }

    // If command is hide-popover
    else if (event.command == "hide-popover") {
      // Popover was hidden
    }

    // If command is toggle-popover
    else if (event.command == "toggle-popover") {
      // Popover was toggled
    }

  });
});

Being able to listen for show-popover and hide-popover is useful as we otherwise have to write a sort of “if opened, do this, else do that” logic from within a toggle or beforetoggle event listener or toggle-popover conditional. But <dialog> elements? Yeah, those benefit more from the command and commandfor attributes than they do from this command JavaScript event.

Another thing that’s available to us via JavaScript is event.source, which is the button that invokes the popover or <dialog>:

if (event.command == "toggle-popover") {
  // Toggle the invoker’s class
  event.source.classList.toggle("active");
}

You can also set the command and commandfor attributes using JavaScript:

const button = document.querySelector("button");
const dialog = document.querySelector("dialog");

button.command = "show-modal";
button.commandForElement = dialog; /* Not dialog.id */

…which is only slightly less verbose than:

button.command = "show-modal";
button.setAttribute("commandfor", dialog.id);

Creating custom commands

The command attribute also accepts custom commands prefixed with two dashes (--). I suppose this makes them like CSS custom properties but for JavaScript events and event handler HTML attributes. The latter observation is maybe a bit (or definitely a lot) controversial since using event handler HTML attributes is considered bad practice. But let’s take a look at that anyway, shall we?

Custom commands look like this:

<button command="--spin-me-a-bit" commandfor="record">Spin me a bit</button>
<button command="--spin-me-a-lot" commandfor="record">Spin me a lot</button>
<button command="--spin-me-right-round" commandfor="record">Spin me right round</button>
const record = document.querySelector("#record");

record.addEventListener("command", event => {
  if (event.command == "--spin-me-a-bit") {
    record.style.rotate = "90deg";
  } else if (event.command == "--spin-me-a-lot") {
    record.style.rotate = "180deg";
  } else if (event.command == "--spin-me-right-round") {
    record.style.rotate = "360deg";
  }
});

event.command must match the string with the dashed (--) prefix.

Are popover and <dialog> the only features that support invoker commands?

According to Open UI, invokers targeting additional elements such as <details> were deferred from the initial release. I think this is because HTML-invoked dialogs and an API that unifies dialogs and popovers is a must-have, whereas other commands (even custom commands) feel more like a nice-to-have deal.

However, based on experimentation (I couldn’t help myself!) web browsers have actually implemented additional invokers to varying degrees. For example, <details> commands work as expected whereas <select> commands match event.command (e.g., show-picker) but fail to actually invoke the method (showPicker()). I missed all of this at first because MDN only mentions dialog and popover.

Open UI also alludes to commands for <input type="file">, <input type="number">, <video>, <audio>, and fullscreen-related methods, but I don’t think that anything is certain at this point.

So, what would be the benefits of invoker commands?

Well, a whole lot less JavaScript for one, especially if more invoker commands are implemented over time. Additionally, we can listen for these commands almost as if they were JavaScript events. But if nothing else, invoker commands simply provide more ways to interact with APIs such as the Dialog and Popover APIs. In a nutshell, it seems like a lot of “dotting i’s” and “crossing-t’s” which is never a bad thing.


Invoker Commands: Additional Ways to Work With Dialog, Popover… and More? originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.



from CSS-Tricks https://ift.tt/soLMA4u
via IFTTT

Tuesday, November 19, 2024

Mostly Clear today!



With a high of F and a low of 17F. Currently, it's 23F and Clear outside.

Current wind speeds: 13 from the Northwest

Pollen: 0

Sunrise: November 19, 2024 at 07:40PM

Sunset: November 20, 2024 at 05:34AM

UV index: 0

Humidity: 64%

via https://ift.tt/iuhBJXp

November 20, 2024 at 10:02AM

Complete CSS Course

Do you subscribe to Piccalilli? You should. If you’re reading that name for the first time, that would be none other than Andy Bell running the ship and he’s reimagined the site from the ground-up after coming out of hibernation this year. You’re likely familiar with Andy’s great writing here on CSS-Tricks.

Andy is more than a great writer — he’s a teacher, too. And you’ll get to see that in spades next week when his brand-new course Andy is more than a great writer — he’s a teacher, too. And you’ll see that in spades next week when his brand-new course Complete CSS is released one week from today on November 26.

As someone who also runs a front-end course, I can tell you it takes a non-trivial amount of time and effort to put something like Complete CSS together. I’ve been able to sneak peek at the course and like love how it’s made for many CSS-Tricks readers — you know CSS and use it regularly but need to ratchet it up from good to great. If my course is for those just getting into CSS, Andy will graduate you from hobbyist to practitioner in Complete CSS. It’s the perfect next step for narrowing the ever-growing learning gaps in this industry.

Early bird price is £189 (~$240) which is a steep cut from the full £249 (~$325) price tag.


Complete CSS Course originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.



from CSS-Tricks https://ift.tt/Gs3mQX9
via IFTTT

Monday, November 18, 2024

Mostly Clear today!



With a high of F and a low of 24F. Currently, it's 35F and Clear outside.

Current wind speeds: 11 from the Northwest

Pollen: 0

Sunrise: November 18, 2024 at 07:39PM

Sunset: November 19, 2024 at 05:34AM

UV index: 0

Humidity: 71%

via https://ift.tt/AOuh1Ct

November 19, 2024 at 10:02AM

Sunday, November 17, 2024

Mostly Cloudy today!



With a high of F and a low of 27F. Currently, it's 31F and Clear outside.

Current wind speeds: 7 from the Southwest

Pollen: 0

Sunrise: November 17, 2024 at 07:38PM

Sunset: November 18, 2024 at 05:35AM

UV index: 0

Humidity: 73%

via https://ift.tt/hDIlRFv

November 18, 2024 at 10:02AM

Saturday, November 16, 2024

Mostly Clear today!



With a high of F and a low of 24F. Currently, it's 32F and Clear outside.

Current wind speeds: 8 from the North

Pollen: 0

Sunrise: November 16, 2024 at 07:37PM

Sunset: November 17, 2024 at 05:36AM

UV index: 0

Humidity: 75%

via https://ift.tt/8jNkZDQ

November 17, 2024 at 10:02AM

Friday, November 15, 2024

Clear today!



With a high of F and a low of 32F. Currently, it's 38F and Clear outside.

Current wind speeds: 19 from the South

Pollen: 0

Sunrise: November 15, 2024 at 07:36PM

Sunset: November 16, 2024 at 05:36AM

UV index: 0

Humidity: 75%

via https://ift.tt/gHelWuR

November 16, 2024 at 10:02AM

Thursday, November 14, 2024

Clear today!



With a high of F and a low of 27F. Currently, it's 35F and Clear outside.

Current wind speeds: 6 from the Southeast

Pollen: 0

Sunrise: November 14, 2024 at 07:34PM

Sunset: November 15, 2024 at 05:37AM

UV index: 0

Humidity: 67%

via https://ift.tt/mcusFUb

November 15, 2024 at 10:02AM

Wednesday, November 13, 2024

Clear today!



With a high of F and a low of 27F. Currently, it's 28F and Clear outside.

Current wind speeds: 9 from the Southwest

Pollen: 0

Sunrise: November 13, 2024 at 07:33PM

Sunset: November 14, 2024 at 05:38AM

UV index: 0

Humidity: 80%

via https://ift.tt/GYXVra2

November 14, 2024 at 10:02AM

Tuesday, November 12, 2024

Showers/Wind today!



With a high of F and a low of 31F. Currently, it's 39F and Partly Cloudy/Wind outside.

Current wind speeds: 22 from the Northwest

Pollen: 0

Sunrise: November 12, 2024 at 07:32PM

Sunset: November 13, 2024 at 05:39AM

UV index: 0

Humidity: 73%

via https://ift.tt/A4fGaEX

November 13, 2024 at 10:02AM

Anchoreum: A New Game for Learning Anchor Positioning

Monday, November 11, 2024

Partly Cloudy today!



With a high of F and a low of 30F. Currently, it's 32F and Clear outside.

Current wind speeds: 9 from the South

Pollen: 0

Sunrise: November 11, 2024 at 07:31PM

Sunset: November 12, 2024 at 05:39AM

UV index: 0

Humidity: 82%

via https://ift.tt/TR4ZV0m

November 12, 2024 at 10:02AM

Tim Brown: Flexible Typesetting is now yours, for free

Another title from A Book Apart has been re-released for free. The latest? Tim Brown’s Flexible Typesetting. I may not be the utmost expert on typography and its best practices but I do remember reading this book (it’s still on the shelf next to me!) thinking maybe, just maybe, I might be able to hold a conversation about it with Robin when I finished it.

I still think I’m in “maybe” territory but that’s not Tim’s fault — I found the book super helpful and approachable for noobs like me who want to up our game. For the sake of it, I’ll drop the chapter titles here to give you an idea of what you’ll get.

  • What is typsetting?
  • Preparing text and code (planning is definitely part of the typesetting process)
  • Selecting typefaces (this one helped me a lot!)
  • Shaping text blocks (modern CSS can help here)
  • Crafting compositions (great if you’re designing for long-form content)
  • Relieving pressure

Tim Brown: Flexible Typesetting is now yours, for free originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.



from CSS-Tricks https://ift.tt/cZJCmU3
via IFTTT

Sunday, November 10, 2024

Partly Cloudy today!



With a high of F and a low of 28F. Currently, it's 37F and Clear outside.

Current wind speeds: 9 from the Southwest

Pollen: 0

Sunrise: November 10, 2024 at 07:30PM

Sunset: November 11, 2024 at 05:40AM

UV index: 0

Humidity: 58%

via https://ift.tt/dDw1Si9

November 11, 2024 at 10:02AM

Saturday, November 9, 2024

Partly Cloudy today!



With a high of F and a low of 29F. Currently, it's 35F and Mostly Cloudy outside.

Current wind speeds: 11 from the West

Pollen: 0

Sunrise: November 9, 2024 at 07:29PM

Sunset: November 10, 2024 at 05:41AM

UV index: 0

Humidity: 88%

via https://ift.tt/s1MQ5pd

November 10, 2024 at 10:02AM

Friday, November 8, 2024

Snow/Wind today!



With a high of F and a low of 29F. Currently, it's 32F and Snow Shower/Wind outside.

Current wind speeds: 24 from the North

Pollen: 0

Sunrise: November 8, 2024 at 07:28PM

Sunset: November 9, 2024 at 05:42AM

UV index: 0

Humidity: 96%

via https://ift.tt/4ovTtO6

November 9, 2024 at 10:02AM

The Different (and Modern) Ways to Toggle Content

If all you have is a hammer, everything looks like a nail.

Abraham Maslow

It’s easy to default to what you know. When it comes to toggling content, that might be reaching for display: none or opacity: 0 with some JavaScript sprinkled in. But the web is more “modern” today, so perhaps now is the right time to get a birds-eye view of the different ways to toggle content — which native APIs are actually supported now, their pros and cons, and some things about them that you might not know (such as any pseudo-elements and other non-obvious stuff).

So, let’s spend some time looking at disclosures (<details> and <summary>), the Dialog API, the Popover API, and more. We’ll look at the right time to use each one depending on your needs. Modal or non-modal? JavaScript or pure HTML/CSS? Not sure? Don’t worry, we’ll go into all that.

Disclosures (<details> and <summary>)

Use case: Accessibly summarizing content while making the content details togglable independently, or as an accordion.

Going in release order, disclosures — known by their elements as <details> and <summary> — marked the first time we were able to toggle content without JavaScript or weird checkbox hacks. But lack of web browser support obviously holds new features back at first, and this one in particular came without keyboard accessibility. So I’d understand if you haven’t used it since it came to Chrome 12 way back in 2011. Out of sight, out of mind, right?

Here’s the low-down:

  • It’s functional without JavaScript (without any compromises).
  • It’s fully stylable without appearance: none or the like.
  • You can hide the marker without non-standard pseudo-selectors.
  • You can connect multiple disclosures to create an accordion.
  • Aaaand… it’s fully animatable, as of 2024.

Marking up disclosures

What you’re looking for is this:

<details>
  <summary>Content summary (always visible)</summary>
  Content (visibility is toggled when summary is clicked on)
</details>

Behind the scenes, the content’s wrapped in a pseudo-element that as of 2024 we can select using ::details-content. To add to this, there’s a ::marker pseudo-element that indicates whether the disclosure’s open or closed, which we can customize.

With that in mind, disclosures actually look like this under the hood:

<details>
  <summary><::marker></::marker>Content summary (always visible)</summary>
  <::details-content>
      Content (visibility is toggled when summary is clicked on)
  </::details-content>
</details>

To have the disclosure open by default, give <details> the open attribute, which is what happens behind the scenes when disclosures are opened anyway.

<details open> ... </details>

Styling disclosures

Let’s be real: you probably just want to lose that annoying marker. Well, you can do that by setting the display property of <summary> to anything but list-item:

summary {
  display: block; /* Or anything else that isn't list-item */
}

Alternatively, you can modify the marker. In fact, the example below utilizes Font Awesome to replace it with another icon, but keep in mind that ::marker doesn’t support many properties. The most flexible workaround is to wrap the content of <summary> in an element and select it in CSS.

<details>
  <summary><span>Content summary</span></summary>
  Content
</details>
details {
  
  /* The marker */
  summary::marker {
    content: "\f150";
    font-family: "Font Awesome 6 Free";
  }

  /* The marker when <details> is open */
  &[open] summary::marker {
    content: "\f151";
  }
  
  /* Because ::marker doesn’t support many properties */
  summary span {
    margin-left: 1ch;
    display: inline-block;
  }
  
}

Creating an accordion with multiple disclosures

To create an accordion, name multiple disclosures (they don’t even have to be siblings) with a name attribute and a matching value (similar to how you’d implement <input type="radio">):

<details name="starWars" open>
  <summary>Prequels</summary>
  <ul>
    <li>Episode I: The Phantom Menace</li>
    <li>Episode II: Attack of the Clones</li>
    <li>Episode III: Revenge of the Sith</li>
  </ul>
</details>

<details name="starWars">
  <summary>Originals</summary>
  <ul>
    <li>Episode IV: A New Hope</li>
    <li>Episode V: The Empire Strikes Back</li>
    <li>Episode VI: Return of the Jedi</li>
  </ul>
</details>

<details name="starWars">
  <summary>Sequels</summary>
  <ul>
    <li>Episode VII: The Force Awakens</li>
    <li>Episode VIII: The Last Jedi</li>
    <li>Episode IX: The Rise of Skywalker</li>
  </ul>
</details>

Using a wrapper, we can even turn these into horizontal tabs:

<div> <!-- Flex wrapper -->
  <details name="starWars" open> ... </details>
  <details name="starWars"> ... </details>
  <details name="starWars"> ... </details>
</div>
div {
  gap: 1ch;
  display: flex;
  position: relative;

  details {
    min-height: 106px; /* Prevents content shift */
      
    &[open] summary,
    &[open]::details-content {
      background: #eee;
    }

    &[open]::details-content {
      left: 0;
      position: absolute;
    } 
  }
}

…or, using 2024’s Anchor Positioning API, vertical tabs (same HTML):

div {
  
  display: inline-grid;
  anchor-name: --wrapper;

  details[open] {
      
    summary,
    &::details-content {
      background: #eee;
    }

    &::details-content {
      position: absolute;
      position-anchor: --wrapper;
      top: anchor(top);
      left: anchor(right);
    } 
  }
}

If you’re looking for some wild ideas on what we can do with the Popover API in CSS, check out John Rhea’s article in which he makes an interactive game solely out of disclosures!

Adding JavaScript functionality

Want to add some JavaScript functionality?

// Optional: select and loop multiple disclosures
document.querySelectorAll("details").forEach(details => {
  details.addEventListener("toggle", () => {
    // The disclosure was toggled
    if (details.open) {
      // The disclosure was opened
    } else {
      // The disclosure was closed
    }
  });    
});

Creating accessible disclosures

Disclosures are accessible as long as you follow a few rules. For example, <summary> is basically a <label>, meaning that its content is announced by screen readers when in focus. If there isn’t a <summary> or <summary> isn’t a direct child of <details> then the user agent will create a label for you that normally says “Details” both visually and in assistive tech. Older web browsers might insist that it be the first child, so it’s best to make it so.

To add to this, <summary> has the role of button, so whatever’s invalid inside a <button> is also invalid inside a <summary>. This includes headings, so you can style a <summary> as a heading, but you can’t actually insert a heading into a <summary>.

The Dialog element (<dialog>)

Use case: Modals

Now that we have the Popover API for non-modal overlays, I think it’s best if we start to think of dialogs as modals even though the show() method does allow for non-modal dialogs. The advantage that the popover attribute has over the <dialog> element is that you can use it to create non-modal overlays without JavaScript, so in my opinion there’s no benefit to non-modal dialogs anymore, which do require JavaScript. For clarity, a modal is an overlay that makes the main document inert, whereas with non-modal overlays the main document remains interactive. There are a few other features that modal dialogs have out-of-the-box as well, including:

  • a stylable backdrop,
  • an autofocus onto the first focusable element within the <dialog> (or, as a backup, the <dialog> itself — include an aria-label in this case),
  • a focus trap (as a result of the main document’s inertia),
  • the esc key closes the dialog, and
  • both the dialog and the backdrop are animatable.Marking up and activating dialogs

Start with the <dialog> element:

<dialog> ... </dialog>

It’s hidden by default and, similar to <details>, we can have it open when the page loads, although it isn’t modal in this scenario since it does not contain interactive content because it doesn’t opened with showModal().

<dialog open> ... </dialog>

I can’t say that I’ve ever needed this functionality. Instead, you’ll likely want to reveal the dialog upon some kind of interaction, such as the click of a button — so here’s that button:

<button data-dialog="dialogA">Open dialogA</button>

Wait, why are we using data attributes? Well, because we might want to hand over an identifier that tells the JavaScript which dialog to open, enabling us to add the dialog functionality to all dialogs in one snippet, like this:

// Select and loop all elements with that data attribute
document.querySelectorAll("[data-dialog]").forEach(button => {
  // Listen for interaction (click)
  button.addEventListener("click", () => {
    // Select the corresponding dialog
    const dialog = document.querySelector(`#${ button.dataset.dialog }`);
    // Open dialog
    dialog.showModal();      
    // Close dialog
    dialog.querySelector(".closeDialog").addEventListener("click", () => dialog.close());
  });
});

Don’t forget to add a matching id to the <dialog> so it’s associated with the <button> that shows it:

<dialog id="dialogA"> <!-- id and data-dialog = dialogA --> ... </dialog>

And, lastly, include the “close” button:

<dialog id="dialogA">
  <button class="closeDialog">Close dialogA</button>
</dialog>

Note: <form method="dialog"> (that has a <button>) or <button formmethod="dialog"> (wrapped in a <form>) also closes the dialog.

How to prevent scrolling when the dialog is open

Prevent scrolling while the modal’s open, with one line of CSS:

body:has(dialog:modal) { overflow: hidden; }

Styling the dialog’s backdrop

And finally, we have the backdrop to reduce distraction from what’s underneath the top layer (this applies to modals only). Its styles can be overwritten, like this:

::backdrop {
  background: hsl(0 0 0 / 90%);
  backdrop-filter: blur(3px); /* A fun property just for backdrops! */
}

On that note, the <dialog> itself comes with a border, a background, and some padding, which you might want to reset. Actually, popovers behave the same way.

Dealing with non-modal dialogs

To implement a non-modal dialog, use:

  • show() instead of showModal()
  • dialog[open] (targets both) instead of dialog:modal

Although, as I said before, the Popover API doesn’t require JavaScript, so for non-modal overlays I think it’s best to use that.

The Popover API (<element popover>)

Use case: Non-modal overlays

Popups, basically. Suitable use cases include tooltips (or toggletips — it’s important to know the difference), onboarding walkthroughs, notifications, togglable navigations, and other non-modal overlays where you don’t want to lose access to the main document. Obviously these use cases are different to those of dialogs, but nonetheless popovers are extremely awesome. Functionally they’re just like just dialogs, but not modal and don’t require JavaScript.

Marking up popovers

To begin, the popover needs an id as well as the popover attribute with the manual value (which means clicking outside of the popover doesn’t close it), the auto value (clicking outside of the popover does close it), or no value (which means the same thing). To be semantic, the popover can be a <dialog>.

<dialog id="tooltipA" popover> ... </dialog>

Next, add the popovertarget attribute to the <button> or <input type="button"> that we want to toggle the popover’s visibility, with a value matching the popover’s id attribute (this is optional since clicking outside of the popover will close it anyway, unless popover is set to manual):

<dialog id="tooltipA" popover>
  <button popovertarget="tooltipA">Hide tooltipA</button>
</dialog>

Place another one of those buttons in your main document, so that you can show the popover. That’s right, popovertarget is actually a toggle (unless you specify otherwise with the popovertargetaction attribute that accepts show, hide, or toggle as its value — more on that later).

Styling popovers

By default, popovers are centered within the top layer (like dialogs), but you probably don’t want them there as they’re not modals, after all.

<main>
  <button popovertarget="tooltipA">Show tooltipA</button>
</main>

<dialog id="tooltipA" popover>
  <button popovertarget="tooltipA">Hide tooltipA</button>
</dialog>

You can easily pull them into a corner using fixed positioning, but for a tooltip-style popover you’d want it to be relative to the trigger that opens it. CSS Anchor Positioning makes this super easy:

main [popovertarget] {
  anchor-name: --trigger;
}

[popover] {
  margin: 0;
  position-anchor: --trigger;
  top: calc(anchor(bottom) + 10px);
  justify-self: anchor-center;
}

/* This also works but isn’t needed
unless you’re using the display property
[popover]:popover-open {
    ...
}
*/

The problem though is that you have to name all of these anchors, which is fine for a tabbed component but overkill for a website with quite a few tooltips. Luckily, we can match an id attribute on the button to an anchor attribute on the popover, which isn’t well-supported as of November 2024 but will do for this demo:

<main>
  <!-- The id should match the anchor attribute -->
  <button id="anchorA" popovertarget="tooltipA">Show tooltipA</button>
  <button id="anchorB" popovertarget="tooltipB">Show tooltipB</button>
</main>

<dialog anchor="anchorA" id="tooltipA" popover>
  <button popovertarget="tooltipA">Hide tooltipA</button>
</dialog>

<dialog anchor="anchorB" id="tooltipB" popover>
  <button popovertarget="tooltipB">Hide tooltipB</button>
</dialog>
main [popovertarget] { anchor-name: --anchorA; } /* No longer needed */

[popover] {
  margin: 0;
  position-anchor: --anchorA; /* No longer needed */
  top: calc(anchor(bottom) + 10px);
  justify-self: anchor-center;
}

The next issue is that we expect tooltips to show on hover and this doesn’t do that, which means that we need to use JavaScript. While this seems complicated considering that we can create tooltips much more easily using ::before/::after/content:, popovers allow HTML content (in which case our tooltips are actually toggletips by the way) whereas content: only accepts text.

Adding JavaScript functionality

Which leads us to this…

Okay, so let’s take a look at what’s happening here. First, we’re using anchor attributes to avoid writing a CSS block for each anchor element. Popovers are very HTML-focused, so let’s use anchor positioning in the same way. Secondly, we’re using JavaScript to show the popovers (showPopover()) on mouseover. And lastly, we’re using JavaScript to hide the popovers (hidePopover()) on mouseout, but not if they contain a link as obviously we want them to be clickable (in this scenario, we also don’t hide the button that hides the popover).

<main>
  <button id="anchorLink" popovertarget="tooltipLink">Open tooltipLink</button>
  <button id="anchorNoLink" popovertarget="tooltipNoLink">Open tooltipNoLink</button>
</main>

<dialog anchor="anchorLink" id="tooltipLink" popover>Has <a href="#">a link</a>, so we can’t hide it on mouseout
  <button popovertarget="tooltipLink">Hide tooltipLink manually</button>
</dialog>

<dialog anchor="anchorNoLink" id="tooltipNoLink" popover>Doesn’t have a link, so it’s fine to hide it on mouseout automatically
  <button popovertarget="tooltipNoLink">Hide tooltipNoLink</button>
</dialog>
[popover] {
  
  margin: 0;
  top: calc(anchor(bottom) + 10px);
  justify-self: anchor-center;

  /* No link? No button needed */
  &:not(:has(a)) [popovertarget] {
    display: none;
  }
}
/* Select and loop all popover triggers */
document.querySelectorAll("main [popovertarget]").forEach((popovertarget) => {
  
  /* Select the corresponding popover */
  const popover = document.querySelector(`#${popovertarget.getAttribute("popovertarget")}`);
  
  /* Show popover on trigger mouseover */
  popovertarget.addEventListener("mouseover", () => {
    popover.showPopover();
  });

  /* Hide popover on trigger mouseout, but not if it has a link */
  if (popover.matches(":not(:has(a))")) {
    popovertarget.addEventListener("mouseout", () => {
      popover.hidePopover();
    });
  }
});

Implementing timed backdrops (and sequenced popovers)

At first, I was sure that popovers having backdrops was an oversight, the argument being that they shouldn’t obscure a focusable main document. But maybe it’s okay for a couple of seconds as long as we can resume what we were doing without being forced to close anything? At least, I think this works well for a set of onboarding tips:

<!-- Re-showing ‘A’ rolls the onboarding back to that step -->
<button popovertarget="onboardingTipA" popovertargetaction="show">Restart onboarding</button>
<!-- Hiding ‘A’ also hides subsequent tips as long as the popover attribute equates to auto -->
<button popovertarget="onboardingTipA" popovertargetaction="hide">Cancel onboarding</button>

<ul>
  <li id="toolA">Tool A</li>
  <li id="toolB">Tool B</li>
  <li id="toolC">Another tool, “C”</li>
  <li id="toolD">Another tool — let’s call this one “D”</li>
</ul>

<!-- onboardingTipA’s button triggers onboardingTipB -->
<dialog anchor="toolA" id="onboardingTipA" popover>
  onboardingTipA <button popovertarget="onboardingTipB" popovertargetaction="show">Next tip</button>
</dialog>

<!-- onboardingTipB’s button triggers onboardingTipC -->
<dialog anchor="toolB" id="onboardingTipB" popover>
  onboardingTipB <button popovertarget="onboardingTipC" popovertargetaction="show">Next tip</button>
</dialog>

<!-- onboardingTipC’s button triggers onboardingTipD -->
<dialog anchor="toolC" id="onboardingTipC" popover>
  onboardingTipC <button popovertarget="onboardingTipD" popovertargetaction="show">Next tip</button>
</dialog>

<!-- onboardingTipD’s button hides onboardingTipA, which in-turn hides all tips -->
<dialog anchor="toolD" id="onboardingTipD" popover>
  onboardingTipD <button popovertarget="onboardingTipA" popovertargetaction="hide">Finish onboarding</button>
</dialog>
::backdrop {
  animation: 2s fadeInOut;
}

[popover] {
  margin: 0;
  align-self: anchor-center;
  left: calc(anchor(right) + 10px);
}
/*
After users have had a couple of
seconds to breathe, start the onboarding
*/
setTimeout(() => {
  document.querySelector("#onboardingTipA").showPopover();
}, 2000);

Again, let’s unpack. Firstly, setTimeout() shows the first onboarding tip after two seconds. Secondly, a simple fade-in-fade-out background animation runs on the backdrop and all subsequent backdrops. The main document isn’t made inert and the backdrop doesn’t persist, so attention is diverted to the onboarding tips while not feeling invasive.

Thirdly, each popover has a button that triggers the next onboarding tip, which triggers another, and so on, chaining them to create a fully HTML onboarding flow. Typically, showing a popover closes other popovers, but this doesn’t appear to be the case if it’s triggered from within another popover. Also, re-showing a visible popover rolls the onboarding back to that step, and, hiding a popover hides it and all subsequent popovers — although that only appears to work when popover equates to auto. I don’t fully understand it but it’s enabled me to create “restart onboarding” and “cancel onboarding” buttons.

With just HTML. And you can cycle through the tips using esc and return.

Creating modal popovers

Hear me out. If you like the HTML-ness of popover but the semantic value of <dialog>, this JavaScript one-liner can make the main document inert, therefore making your popovers modal:

document.querySelectorAll("dialog[popover]").forEach(dialog => dialog.addEventListener("toggle", () => document.body.toggleAttribute("inert")));

However, the popovers must come after the main document; otherwise they’ll also become inert. Personally, this is what I’m doing for modals anyway, as they aren’t a part of the page’s content.

<body>
  <!-- All of this will become inert -->
</body>

<!-- Therefore, the modals must come after -->
<dialog popover> ... </dialog>

Aaaand… breathe

Yeah, that was a lot. But…I think it’s important to look at all of these APIs together now that they’re starting to mature, in order to really understand what they can, can’t, should, and shouldn’t be used for. As a parting gift, I’ll leave you with a transition-enabled version of each API:


The Different (and Modern) Ways to Toggle Content originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.



from CSS-Tricks https://ift.tt/73KyIGB
via IFTTT

Mostly Clear today!

With a high of F and a low of 15F. Currently, it's 14F and Clear outside. Current wind speeds: 13 from the Southwest Pollen: 0 S...