Tag: HTML

A Perfect Table of Contents With HTML + CSS

Earlier this year, I self-published an ebook called Understanding JavaScript Promises (free for download). Even though I didn’t have any intention of turning it into a print book, enough people reached out inquiring about a print version that I decided to self-publish that as well .I thought it would be an easy exercise using HTML and CSS to generate a PDF and then send it off to the printer. What I didn’t realize was that I didn’t have an answer to an important part of a print book: the table of contents.

The makeup of a table of contents

At its core, a table of contents is fairly simple. Each line represents a part of a book or webpage and indicates where you can find that content. Typically, the lines contain three parts:

  1. The title of the chapter or section
  2. Leaders (i.e. those dots, dashes, or lines) that visually connect the title to the page number
  3. The page number

A table of contents is easy to generate inside of word processing tools like Microsoft Word or Google Docs, but because my content was in Markdown and then transformed into HTML, that wasn’t a good option for me. I wanted something automated that would work with HTML to generate the table of contents in a format that was suitable for print. I also wanted each line to be a link so it could be used in webpages and PDFs to navigate around the document. I also wanted dot leaders between the title and page number.

And so I began researching.

I came across two excellent blog posts on creating a table of contents with HTML and CSS. The first was “Build a Table of Contents from your HTML” by Julie Blanc. Julie worked on PagedJS, a polyfill for missing paged media features in web browsers that properly formats documents for print. I started with Julie’s example, but found that it didn’t quite work for me. Next, I found Christoph Grabo’s “Responsive TOC leader lines with CSS” post, which introduced the concept of using CSS Grid (as opposed to Julie’s float-based approach) to make alignment easier. Once again, though, his approach wasn’t quite right for my purposes.

After reading these two posts, though, I felt I had a good enough understanding of the layout issues to embark on my own. I used pieces from both blog posts as well as adding some new HTML and CSS concepts into the approach to come up with a result I’m happy with.

Choosing the correct markup

When deciding on the correct markup for a table of contents, I thought primarily about the correct semantics. Fundamentally, a table of contents is about a title (chapter or subsection) being tied to a page number, almost like a key-value pair. That led me to two options:

  • One option is to use a table (<table>) with one column for the title and one column for the page.
  • Then there’s the often unused and forgotten definition list (<dl>) element. It also acts as a key-value map. So, once again, the relationship between the title and the page number would be obvious.

Either of these seemed like good options until I realized that they really only work for single-level tables of contents, namely, only if I wanted to have a table of contents with just chapter names. If I wanted to show subsections in the table of contents, though, I didn’t have any good options. Table elements aren’t great for hierarchical data, and while definition lists can technically be nested, the semantics didn’t seem correct. So, I went back to the drawing board.

I decided to build off of Julie’s approach and use a list; however, I opted for an ordered list (<ol>) instead of an unordered list (<ul>). I think an ordered list is more appropriate in this case. A table of contents represents a list of chapters and subheadings in the order in which they appear in the content. The order matters and shouldn’t get lost in the markup.

Unfortunately, using an ordered list means losing the semantic relationship between the title and the page number, so my next step was to re-establish that relationship within each list item. The easiest way to solve this is to simply insert the word “page” before the page number. That way, the relationship of the number relative to the text is clear, even without any other visual distinction.

Here’s a simple HTML skeleton that formed the basis of my markup:

<ol class="toc-list">   <li>     <a href="#link_to_heading">       <span class="title">Chapter or subsection title</span>       <span class="page">Page 1</span>     </a>      <ol>       <!-- subsection items -->     </ol>   </li> </ol>

Applying styles to the table of contents

Once I had established the markup I planned to use, the next step was to apply some styles.

First, I removed the autogenerated numbers. You can choose to keep the autogenerated numbers in your own project if you’d like, but it’s common for books to have unnumbered forewords and afterwords included in the list of chapters, which makes the autogenerated numbers incorrect.

For my purpose, I would fill in the chapter numbers manually then adjust the layout so the top-level list doesn’t have any padding (thus aligning it with paragraphs) and each embedded list is indented by two spaces. I chose to use a 2ch padding value because I still wasn’t quite sure which font I would use. The ch length unit allows the padding to be relative to the width of a character — no matter what font is used — rather than an absolute pixel size that could wind up looking inconsistent.

Here’s the CSS I ended up with:

.toc-list, .toc-list ol {   list-style-type: none; }  .toc-list {   padding: 0; }  .toc-list ol {   padding-inline-start: 2ch; }

Sara Soueidan pointed out to me that WebKit browsers remove list semantics when list-style-type is none, so I needed to add role="list" into the HTML to preserve it:

<ol class="toc-list" role="list">   <li>     <a href="#link_to_heading">       <span class="title">Chapter or subsection title</span>       <span class="page">Page 1</span>     </a>      <ol role="list">       <!-- subsection items -->     </ol>   </li> </ol>

Styling the title and page number

With the list styled to my liking, it was time to move on to styling an individual list item. For each item in the table of contents, the title and page number must be on the same line, with the title to the left and the page number aligned to the right.

You might be thinking, “No problem, that’s what flexbox is for!” You aren’t wrong! Flexbox can indeed achieve the correct title-page alignment. But there are some tricky alignment issues when the leaders are added, so I instead opted to go with Christoph’s approach using a grid, which as a bonus as it also helps with multiline titles. Here is the CSS for an individual item:

.toc-list li > a {   text-decoration: none;   display: grid;   grid-template-columns: auto max-content;   align-items: end; }  .toc-list li > a > .page {   text-align: right; }

The grid has two columns, the first of which is auto-sized to fill up the entire width of the container, minus the second column, which is sized to max-content. The page number is aligned to the right, as is traditional in a table of contents.

The only other change I made at this point was to hide the “Page” text. This is helpful for screen readers but unnecessary visually, so I used a traditional visually-hidden class to hide it from view:

.visually-hidden {   clip: rect(0 0 0 0);   clip-path: inset(100%);   height: 1px;   overflow: hidden;   position: absolute;   width: 1px;   white-space: nowrap; }

And, of course, the HTML needs to be updated to use that class:

<ol class="toc-list" role="list">   <li>     <a href="#link_to_heading">       <span class="title">Chapter or subsection title</span>       <span class="page"><span class="visually-hidden">Page</span> 1</span>     </a>      <ol role="list">       <!-- subsection items -->     </ol>   </li> </ol>

With this foundation in place, I moved on to address the leaders between the title and the page.

Creating dot leaders

Leaders are so common in print media that you might be wondering, why doesn’t CSS already support that? The answer is: it does. Well, kind of.

There is actually a leader() function defined in the CSS Generated Content for Paged Media specification. However, as with much of the paged media specifications, this function isn’t implemented in any browsers, therefore excluding it as an option (at least at the time I’m writing this). It’s not even listed on caniuse.com, presumably because no one has implemented it and there are no plans or signals that they will.

Fortunately, both Julie and Christoph already addressed this problem in their respective posts. To insert the dot leaders, they both used a ::after pseudo-element with its content property set to a very long string of dots, like this:

.toc-list li > a > .title {   position: relative;   overflow: hidden; }  .toc-list li > a .title::after {   position: absolute;   padding-left: .25ch;   content: " . . . . . . . . . . . . . . . . . . . "       ". . . . . . . . . . . . . . . . . . . . . . . "       ". . . . . . . . . . . . . . . . . . . . . . . "       ". . . . . . . . . . . . . . . . . . . . . . . "       ". . . . . . . . . . . . . . . . . . . . . . . "       ". . . . . . . . . . . . . . . . . . . . . . . "       ". . . . . . . . . . . . . . . . . . . . . . . ";   text-align: right; }

The ::after pseudo-element is set to an absolute position to take it out of the flow of the page and avoid wrapping to other lines. The text is aligned to the right because we want the last dots of each line flush to the number at the end of the line. (More on the complexities of this later.) The .title element is set to have a relative position so the ::after pseudo-element doesn’t break out of its box. Meanwhile, the overflow is hidden so all those extra dots invisible. The result is a pretty table of contents with dot leaders.

However, there’s something else that needs consideration.

Sara also pointed out to me that all of those dots count as text to screen readers. So what do you hear? “Introduction dot dot dot dot…” until all of the dots are announced. That’s an awful experience for screen reader users.

The solution is to insert an additional element with aria-hidden set to true and then use that element to insert the dots. So the HTML becomes:

<ol class="toc-list" role="list">   <li>     <a href="#link_to_heading">       <span class="title">Chapter or subsection title<span class="leaders" area-hidden="true"></span></span>       <span class="page"><span class="visually-hidden">Page</span> 1</span>     </a>      <ol role="list">       <!-- subsection items -->     </ol>   </li> </ol>

And the CSS becomes:

.toc-list li > a > .title {   position: relative;   overflow: hidden; }  .toc-list li > a .leaders::after {   position: absolute;   padding-left: .25ch;   content: " . . . . . . . . . . . . . . . . . . . "       ". . . . . . . . . . . . . . . . . . . . . . . "       ". . . . . . . . . . . . . . . . . . . . . . . "       ". . . . . . . . . . . . . . . . . . . . . . . "       ". . . . . . . . . . . . . . . . . . . . . . . "       ". . . . . . . . . . . . . . . . . . . . . . . "       ". . . . . . . . . . . . . . . . . . . . . . . ";   text-align: right; }

Now screen readers will ignore the dots and spare users the frustration of listening to multiple dots being announced.

Finishing touches

At this point, the table of contents component looks pretty good, but it could use some minor detail work. To start, most books visually offset chapter titles from subsection titles, so I made the top-level items bold and introduced a margin to separate subsections from the chapters that followed:

.toc-list > li > a {   font-weight: bold;   margin-block-start: 1em; }

Next, I wanted to clean up the alignment of the page numbers. Everything looked okay when I was using a fixed-width font, but for variable-width fonts, the leader dots could end up forming a zigzag pattern as they adjust to the width of a page number. For instance, any page number with a 1 would be narrower than others, resulting in leader dots that are misaligned with the dots on previous or following lines.

Misaligned numbers and dots in a table of contents.

To fix this problem, I set font-variant-numeric to tabular-nums so all numbers are treated with the same width. By also setting the minimum width to 2ch, I ensured that all numbers with one or two digits are perfectly aligned. (You may want to set this to 3ch if your project has more than 100 pages.) Here is the final CSS for the page number:

.toc-list li > a > .page {   min-width: 2ch;   font-variant-numeric: tabular-nums;   text-align: right; }
Aligned leader dots in a table of contents.

And with that, the table of contents is complete!

Conclusion

Creating a table of contents with nothing but HTML and CSS was more of a challenge than I expected, but I’m very happy with the result. Not only is this approach flexible enough to accommodate chapters and subsections, but it handles sub-subsections nicely without updating the CSS. The overall approach works on web pages where you want to link to the various locations of content, as well as PDFs where you want the table of contents to link to different pages. And of course, it also looks great in print if you’re ever inclined to use it in a brochure or book.

I’d like to thank Julie Blanc and Christoph Grabo for their excellent blog posts on creating a table of contents, as both of those were invaluable when I was getting started. I’d also like to thank Sara Soueidan for her accessibility feedback as I worked on this project.


A Perfect Table of Contents With HTML + CSS originally published on CSS-Tricks. You should get the newsletter.

CSS-Tricks

, , ,

Dialog Components: Go Native HTML or Roll Your Own?

As the author of a library called AgnosticUI, I’m always on the lookout for new components. And recently, I decided to dig in and start work on a new dialog (aka modal) component. That’s something many devs like to have in their toolset and my goal was to make the best one possible, with an extra special focus on making it inclusive and accessible.

My first thought was that I would avoid any dependencies and bite the bullet to build my own dialog component. As you may know, there’s a new <dialog> element making the rounds and I figured using it as a starting point would be the right thing, especially in the inclusiveness and accessibilities departments.

But, after doing some research, I instead elected to leverage a11y-dialog by Kitty Giraudel. I even wrote adapters so it integrates smoothly with Vue 3, Svelte, and Angular. Kitty has long offered a React adapter as well.

Why did I go that route? Let me take you through my thought process.

First question: Should I even use the native <dialog> element?

The native <dialog> element is being actively improved and will likely be the way forward. But, it still has some issues at the moment that Kitty pointed out quite well:

  1. Clicking the backdrop overlay does not close the dialog by default
  2. The alertdialog ARIA role used for alerts simply does not work with the native <dialog> element. We’re supposed to use that role when a dialog requires a user’s response and shouldn’t be closed by clicking the backdrop, or by pressing ESC.
  3. The <dialog> element comes with a ::backdrop pseudo-element but it is only available when a dialog is programmatically opened with dialog.showModal().

And as Kitty also points out, there are general issues with the element’s default styles, like the fact they are left to the browser and will require JavaScript. So, it’s sort of not 100% HTML anyway.

Here’s a pen demonstrating these points:

Now, some of these issues may not affect you or whatever project you’re working on specifically, and you may even be able to work around things. If you still would like to utilize the native dialog you should see Adam Argyle’s wonderful post on building a dialog component with native dialog.

OK, let’s discuss what actually are the requirements for an accessible dialog component…

What I’m looking for

I know there are lots of ideas about what a dialog component should or should not do. But as far as what I was personally going after for AgnosticUI hinged on what I believe make for an accessible dialog experience:

  1. The dialog should close when clicking outside the dialog (on the backdrop) or when pressing the ESC key.
  2. It should trap focus to prevent tabbing out of the component with a keyboard.
  3. It should allow forwarding tabbing with TAB and backward tabbing with SHIFT+TAB.
  4. It should return focus back to the previously focused element when closed.
  5. It should correctly apply aria-* attributes and toggles.
  6. It should provide Portals (only if we’re using it within a JavaScript framework).
  7. It should support the alertdialog ARIA role for alert situations.
  8. It should prevent the underlying body from scrolling, if needed.
  9. It would be great if our implementation could avoid the common pitfalls that come with the native <dialog> element.
  10. It would ideally provide a way to apply custom styling while also taking the prefers-reduced-motion user preference query as a further accessibility measure.

I’m not the only one with a wish list. You might want to see Scott O’Hara’s article on the topic as well as Kitty’s full write-up on creating an accessible dialog from scratch for more in-depth coverage.

It should be clear right about now why I nixed the native <dialog> element from my component library. I believe in the work going into it, of course, but my current needs simply outweigh the costs of it. That’s why I went with Kitty’s a11y-dialog as my starting point.

Auditing <dialog> accessibility

Before trusting any particular dialog implementation, it’s worth making sure it fits the bill as far as your requirements go. With my requirements so heavily leaning on accessibility, that meant auditing a11y-dialog.

Accessibility audits are a profession of their own. And even if it’s not my everyday primary focus, I know there are some things that are worth doing, like:

This is quite a lot of work, as you might imagine (or know from experience). It’s tempting to take a path of less resistance and try automating things but, in a study conducted by Deque Systems, automated tooling can only catch about 57% of accessibility issues. There’s no substitute for good ol’ fashioned hard work.

The auditing environment

The dialog component can be tested in lots of places, including Storybook, CodePen, CodeSandbox, or whatever. For this particular test, though, I prefer instead to make a skeleton page and test locally. This way I’m preventing myself from having to validate the validators, so to speak. Having to use, say, a Storybook-specific add-on for a11y verification is fine if you’re already using Storybook on your own components, but it adds another layer of complexity when testing the accessibility of an external component.

A skeleton page can verify the dialog with manual checks, existing a11y tooling, and screen readers. If you’re following along, you’ll want to run this page via a local server. There are many ways to do that; one is to use a tool called serve, and npm even provides a nice one-liner npx serve <DIRECTORY> command to fire things up.

Let’s do an example audit together!

I’m obviously bullish on a11y-dialog here, so let’s put it to the test and verify it using some of the the recommended approaches we’ve covered.

Again, all I’m doing here is starting with an HTML. You can use the same one I am (complete with styles and scripts baked right in).

View full code
<!DOCTYPE html> <html lang="en">   <head>     <meta charset="UTF-8">     <meta name="viewport" content="width=device-width, initial-scale=1.0">     <meta http-equiv="X-UA-Compatible" content="ie=edge">     <title>A11y Dialog Test</title>     <style>       .dialog-container {         display: flex;         position: fixed;         top: 0;         left: 0;         bottom: 0;         right: 0;         z-index: 2;       }              .dialog-container[aria-hidden='true'] {         display: none;       }              .dialog-overlay {         position: fixed;         top: 0;         left: 0;         bottom: 0;         right: 0;         background-color: rgb(43 46 56 / 0.9);         animation: fade-in 200ms both;       }              .dialog-content {         background-color: rgb(255, 255, 255);         margin: auto;         z-index: 2;         position: relative;         animation: fade-in 400ms 200ms both, slide-up 400ms 200ms both;         padding: 1em;         max-width: 90%;         width: 600px;         border-radius: 2px;       }              @media screen and (min-width: 700px) {         .dialog-content {           padding: 2em;         }       }              @keyframes fade-in {         from {           opacity: 0;         }       }              @keyframes slide-up {         from {           transform: translateY(10%);         }       }        /* Note, for brevity we haven't implemented prefers-reduced-motion */              .dialog h1 {         margin: 0;         font-size: 1.25em;       }              .dialog-close {         position: absolute;         top: 0.5em;         right: 0.5em;         border: 0;         padding: 0;         background-color: transparent;         font-weight: bold;         font-size: 1.25em;         width: 1.2em;         height: 1.2em;         text-align: center;         cursor: pointer;         transition: 0.15s;       }              @media screen and (min-width: 700px) {         .dialog-close {           top: 1em;           right: 1em;         }       }              * {         box-sizing: border-box;       }              body {         font: 125% / 1.5 -apple-system, BlinkMacSystemFont, Segoe UI, Helvetica, Arial, sans-serif;         padding: 2em 0;       }              h1 {         font-size: 1.6em;         line-height: 1.1;         font-family: 'ESPI Slab', sans-serif;         margin-bottom: 0;       }              main {         max-width: 700px;         margin: 0 auto;         padding: 0 1em;       }     </style>     <script defer src="https://cdn.jsdelivr.net/npm/a11y-dialog@7/dist/a11y-dialog.min.js"></script>   </head>    <body>     <main>       <div class="dialog-container" id="my-dialog" aria-hidden="true" aria-labelledby="my-dialog-title" role="dialog">         <div class="dialog-overlay" data-a11y-dialog-hide></div>         <div class="dialog-content" role="document">           <button data-a11y-dialog-hide class="dialog-close" aria-label="Close this dialog window">             ×           </button>           <a href="https://www.yahoo.com/" target="_blank">Rando Yahoo Link</a>              <h1 id="my-dialog-title">My Title</h1>           <p id="my-dialog-description">             Some description of what's inside this dialog…           </p>         </div>       </div>       <button type="button" data-a11y-dialog-show="my-dialog">         Open the dialog       </button>     </main>     <script>       // We need to ensure our deferred A11yDialog has       // had a chance to do its thing ;-)       window.addEventListener('DOMContentLoaded', (event) => {         const dialogEl = document.getElementById('my-dialog')         const dialog = new A11yDialog(dialogEl)       });     </script>   </body>  </html>

I know, we’re ignoring a bunch of best practices (what, styles in the <head>?!) and combined all of the HTML, CSS, and JavaScript in one file. I won’t go into the details of the code as the focus here is testing for accessibility, but know that this test requires an internet connection as we are importing a11y-dialog from a CDN.

First, the manual checks

I served this one-pager locally and here are my manual check results:

Feature Result
It should close when clicking outside the dialog (on the backdrop) or when pressing the ESC key.
It ought to trap focus to prevent tabbing out of the component with a keyboard.
It should allow forwarding tabbing with TAB and backward tabbing with SHIFT+TAB.
It should return focus back to the previously focused element when closed.
It should correctly apply aria-* attributes and toggles.
I verified this one “by eye” after inspecting the elements in the DevTools Elements panel.
It should provide Portals. Not applicable.
This is only useful when implementing the element with React, Svelte, Vue, etc. We’ve statically placed it on the page with aria-hidden for this test.
It should support for the alertdialog ARIA role for alert situations.
You’ll need to do two things:

First, remove data-a11y-dialog-hide from the overlay in the HTML so that it is <div class="dialog-overlay"></div>. Replace the dialog role with alertdialog so that it becomes:

<div class="dialog-container" id="my-dialog" aria-hidden="true" aria-labelledby="my-dialog-title" aria-describedby="my-dialog-description" role="alertdialog">

Now, clicking on the overlay outside of the dialog box does not close the dialog, as expected.

It should prevent the underlying body from scrolling, if needed.
I didn’t manually test but this, but it is clearly available per the documentation.
It should avoid the common pitfalls that come with the native <dialog> element.
This component does not rely on the native <dialog> which means we’re good here.

Next, let’s use some a11y tooling

I used Lighthouse to test the component both on a desktop computer and a mobile device, in two different scenarios where the dialog is open by default, and closed by default.

a11y-dialog Lighthouse testing, score 100.

I’ve found that sometimes the tooling doesn’t account for DOM elements that are dynamically shown or hidden DOM elements, so this test ensures I’m getting full coverage of both scenarios.

I also tested with IBM Equal Access Accessibility Checker. Generally, this tool will give you a red violation error if there’s anything egregious wrong. It will also ask you to manually review certain items. As seen here, there a couple of items for manual review, but no red violations.

a11y-dialog — tested with IBM Equal Access Accessibility Checker

Moving on to screen readers

Between my manual and tooling checks, I’m already feeling relatively confident that a11y-dialog is an accessible option for my dialog of choice. However, we ought to do our due diligence and consult a screen reader.

VoiceOver is the most convenient screen reader for me since I work on a Mac at the moment, but JAWS and NVDA are big names to look at as well. Like checking for UI consistency across browsers, it’s probably a good idea to test on more than one screen reader if you can.

VoiceOver caption over the a11y-modal example.

Here’s how I conducted the screen reader part of the audit with VoiceOver. Basically, I mapped out what actions needed testing and confirmed each one, like a script:

Step Result
The dialog component’s trigger button is announced. “Entering A11y Dialog Test, web content.”
The dialog should open when pressing CTRL+ALT +Space should show the dialog. “Dialog. Some description of what’s inside this dialog. You are currently on a dialog, inside of web content.”
The dialog should TAB to and put focus on the component’s Close button. “Close this dialog button. You are currently on a button, inside of web content.”
Tab to the link element and confirm it is announced. “Link, Rando Yahoo Link”
Pressing the SPACE key while focused on the Close button should close the dialog component and return to the last item in focus.

Testing with people

If you’re thinking we’re about to move on to testing with real people, I was unfortunately unable to find someone. If I had done this, though, I would have used a similar set of steps for them to run through while I observe, take notes, and ask a few questions about the general experience.

As you can see, a satisfactory audit involves a good deal of time and thought.

Fine, but I want to use a framework’s dialog component

That’s cool! Many frameworks have their own dialog component solution, so there’s lots to choose from. I don’t have some amazing spreadsheet audit of all the frameworks and libraries in the wild, and will spare you the work of evaluating them all.

Instead, here are some resources that might be good starting points and considerations for using a dialog component in some of the most widely used frameworks.

Disclaimer: I have not tested these personally. This is all stuff I found while researching.

Angular dialog options

In 2020, Deque published an article that audits Angular component libraries and the TL;DR was that Material (and its Angular/CDK library) and ngx-bootstrap both appear to provide decent dialog accessibility.

React dialog options

Reakit offers a dialog component that they claim is compliant with WAI-ARIA dialog guidelines, and chakra-ui appears to pay attention to its accessibility. Of course, Material is also available for React, so that’s worth a look as well. I’ve also heard good things about reach/dialog and Adobe’s @react-aria/dialog.

Vue dialog options

I’m a fan of Vuetensils, which is Austin Gil’s naked (aka headless) components library, which just so happens to have a dialog component. There’s also Vuetify, which is a popular Material implementation with a dialog of its own. I’ve also crossed paths with PrimeVue, but was surprised that its dialog component failed to return focus to the original element.

Svelte dialog options

You might want to look at svelte-headlessui. Material has a port in svelterial that is also worth a look. It seems that many current SvelteKit users prefer to build their own component sets as SvelteKit’s packaging idiom makes it super simple to do. If this is you, I would definitely recommend considering svelte-a11y-dialog as a convenient means to build custom dialogs, drawers, bottom sheets, etc.

I’ll also point out that my AgnosticUI library wraps the React, Vue, Svelte and Angular a11y-dialog adapter implementations we’ve been talking about earlier.

Bootstrap, of course

Bootstrap is still something many folks reach for, and unsurprisingly, it offers a dialog component. It requires you to follow some steps in order to make the modal accessible.

If you have other inclusive and accessible library-based dialog components that merit consideration, I’d love to know about them in the comments!

But I’m creating a custom design system

If you’re creating a design system or considering some other roll-your-own dialog approach, you can see just how many things need to be tested and taken into consideration… all for one component! It’s certainly doable to roll your own, of course, but I’d say it’s also extremely prone to error. You might ask yourself whether the effort is worthwhile when there are already battle-tested options to choose from.

I’ll simply leave you with something Scott O’Hara — co-editor of ARIA in HTML and HTML AAM specifications in addition to just being super helpful with all things accessibility — points out:

You could put in the effort to add in those extensions, or you could use a robust plugin like a11y-dialog and ensure that your dialogs will have a pretty consistent experience across all browsers.

Back to my objective…

I need that dialog to support React, Vue, Svelte, and Angular implementations.

I mentioned earlier that a11y-dialog already has ports for Vue and React. But the Vue port hasn’t yet been updated for Vue 3. Well, I was quite happy to spend the time I would have spent creating what likely would have been a buggy hand-rolled dialog component toward helping update the Vue port. I also added a Svelte port and one for Angular too. These are both very new and I would consider them experimental beta software at time of writing. Feedback welcome, of course!

It can support other components, too!

I think it’s worth pointing out that a dialog uses the same underlying concept for hiding and showing that can be used for a drawer (aka off-canvas) component. For example, if we borrow the CSS we used in our dialog accessibility audit and add a few additional classes, then a11y-dialog can be transformed into a working and effective drawer component:

.drawer-start { right: initial; } .drawer-end { left: initial; } .drawer-top { bottom: initial; } .drawer-bottom { top: initial; }  .drawer-content {   margin: initial;   max-width: initial;   width: 25rem;   border-radius: initial; }  .drawer-top .drawer-content, .drawer-bottom .drawer-content {   width: 100%; }

These classes are used in an additive manner, essentially extending the base dialog component. This is exactly what I have started to do as I add my own drawer component to AgnosticUI. Saving time and reusing code FTW!

Wrapping up

Hopefully I’ve given you a good idea of the thinking process that goes into the making and maintenance of a component library. Could I have hand-rolled my own dialog component for the library? Absolutely! But I doubt it would have yielded better results than what a resource like Kitty’s a11y-dialog does, and the effort is daunting. There’s something cool about coming up with your own solution — and there may be good situations where you want to do that — but probably not at the cost of sacrificing something like accessibility.

Anyway, that’s how I arrived at my decision. I learned a lot about the native HTML <dialog> and its accessibility along the way, and I hope my journey gave you some of those nuggets too.


Dialog Components: Go Native HTML or Roll Your Own? originally published on CSS-Tricks. You should get the newsletter.

CSS-Tricks

, , , ,
[Top]

Write HTML, the HTML Way (Not the XHTML Way)

You may not use XHTML (anymore), but when you write HTML, you may be more influenced by XHTML than you think. You are very likely writing HTML, the XHTML way.

What is the XHTML way of writing HTML, and what is the HTML way of writing HTML? Let’s have a look.

HTML, XHTML, HTML

In the 1990s, there was HTML. In the 2000s, there was XHTML. Then, in the 2010s, we switched back to HTML. That’s the simple story.

You can tell by the rough dates of the specifications, too: HTML “1” 1992, HTML 2.0 1995, HTML 3.2 1997, HTML 4.01 1999; XHTML 1.0 2000, XHTML 1.1 2001; “HTML5” 2007.

XHTML became popular when everyone believed XML and XML derivatives were the future. “XML all the things.” For HTML, this had a profound effect: The effect that we learned to write it the XHTML way.

The XHTML way of writing HTML

The XHTML way is well-documented, because XHTML 1.0 describes in great detail in its section on “Differences with HTML 4”:

  • Documents must be well-formed.
  • Element and attribute names must be in lower case.
  • For non-empty elements, end tags are required.
  • Attribute values must always be quoted.
  • Attribute minimization is not supported.
  • Empty elements need to be closed.
  • White space handling in attribute values is done according to XML.
  • Script and style elements need CDATA sections.
  • SGML exclusions are not possible.
  • The elements with id and name attributes, like a, applet, form, frame, iframe, img, and map, should only use id.
  • Attributes with pre-defined value sets are case-sensitive.
  • Entity references as hex values must be in lowercase.

Does this look familiar? With the exception of marking CDATA content, as well as dealing with SGML exclusions, you probably follow all of these rules. All of them.

Although XHTML is dead, many of these rules have never been questioned again. Some have even been elevated to “best practices” for HTML.

That is the XHTML way of writing HTML, and its lasting impact on the field.

The HTML way of writing HTML

One way of walking us back is to negate the rules imposed by XHTML. Let’s actually do this (without the SGML part, because HTML isn’t based on SGML anymore):

  • Documents may not be well-formed.
  • Element and attribute names may not be in lower case.
  • For non-empty elements, end tags are not always required.
  • Attribute values may not always be quoted.
  • Attribute minimization is supported.
  • Empty elements don’t need to be closed.
  • White space handling in attribute values isn’t done according to XML.
  • Script and style elements don’t need CDATA sections.
  • The elements with id and name attributes may not only use id.
  • Attributes with pre-defined value sets are not case-sensitive.
  • Entity references as hex values may not only be in lowercase.

Let’s remove the esoteric things; the things that don’t seem relevant. This includes XML whitespace handling, CDATA sections, doubling of name attribute values, the case of pre-defined value sets, and hexadecimal entity references:

  • Documents may not be well-formed.
  • Element and attribute names may not be in lowercase.
  • For non-empty elements, end tags are not always required.
  • Attribute values may not always be quoted.
  • Attribute minimization is supported.
  • Empty elements don’t need to be closed.

Peeling away from these rules, this looks a lot less like we’re working with XML, and more like working with HTML. But we’re not done yet.

“Documents may not be well-formed” suggests that it was fine if HTML code was invalid. It was fine for XHTML to point to wellformedness because of XML’s strict error handling. But while HTML documents work even when they contain severe syntax and wellformedness issues, it’s neither useful for the professional — nor our field — to use and abuse this resilience. (I’ve argued this case before in my article, “In Critical Defense of Frontend Development.”)

The HTML way would therefore not suggest “documents may not be well-formed.” It would also be clear that not only end, but also start tags aren’t always required. Rephrasing and reordering, this is the essence:

  • Start and end tags are not always required.
  • Empty elements don’t need to be closed.
  • Element and attribute names may be lower or upper case.
  • Attribute values may not always be quoted.
  • Attribute minimization is supported.

Examples

How does this look like in practice? For start and end tags, be aware that many tags are optional. A paragraph and a list, for example, are written like this in XHTML:

<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p> <ul>   <li>Praesent augue nisl</li>   <li>Lobortis nec bibendum ut</li>   <li>Dictum ac quam</li> </ul>

In HTML, however, you can write them using only this code (which is valid):

<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. <ul>   <li>Praesent augue nisl   <li>Lobortis nec bibendum ut   <li>Dictum ac quam </ul>

Developers also learned to write void elements, like so:

<br />

This is something XHTML brought to HTML, but as the slash has no effect on void elements, you only need this:

<br>

In HTML, you can also just write everything in all caps:

<A HREF="https://css-tricks.com/">CSS-Tricks</A>

It looks like you’re yelling and you may not like it, but it’s okay to write it like this.

When you want to condense that link, HTML offers you the option to leave out certain quotes:

<A HREF=https://css-tricks.com/>CSS-Tricks</A>

As a rule of thumb, when the attribute value doesn’t contain a space or an equal sign, it’s usually fine to drop the quotes.

Finally, HTML–HTML — not XHTML–HTML — also allows to minimize attributes. That is, instead of marking an input element as required and read-only, like this:

<input type="text" required="required" readonly="readonly">

You can minimize the attributes:

<input type="text" required readonly>

If you’re not only taking advantage of the fact that the quotes aren’t needed, but that text is the default for the type attribute here (there are more such unneeded attribute–value combinations), you get an example that shows HTML in all its minimal beauty:

<input required readonly>

Write HTML, the HTML way

The above isn’t a representation of where HTML was in the 90s. HTML, back then, was loaded with <table> elements for layout, packed with presentational code, largely invalid (as it’s still today), with wildly varying user agent support. Yet it’s the essence of what we would have wanted to keep if XML and XHTML hadn’t come around.

If you’re open to a suggestion of what a more comprehensive, contemporary way of writing HTML could look like, I have one. (HTML is my main focus area, so I’m augmenting this by links to some of my articles.)

  1. Respect syntax and semantics.
  2. Use the options HTML gives you, as long as you do so consistently.
    • Remember that element and attribute names may be lowercase or uppercase.
  3. Keep use of HTML to the absolute minimum
    • Remember that presentational and behavioral markup is to be handled by CSS and JavaScript instead.
    • Remember that start and end tags are not always required.
    • Remember that empty elements don’t need to be closed.
    • Remember that some attributes have defaults that allow these attribute–value pairs to be omitted.
    • Remember that attribute values may not always be quoted.
    • Remember that attribute minimization is supported.

It’s not a coincidence that this resembles the three ground rules for HTML, that it works with the premise of a smaller payload also leading to faster sites, and that this follows the school of minimal web development. None of this is new — our field could merely decide to rediscover it. Tooling is available, too: html-minifier is probably the most established and able to handle all HTML optimizations.

You’ve learned HTML the XHTML way. HTML isn’t XHTML. Rediscover HTML, and help shape a new, modern way of writing HTML — which acknowledges, but isn’t necessarily based on XML.


Write HTML, the HTML Way (Not the XHTML Way) originally published on CSS-Tricks. You should get the newsletter.

CSS-Tricks

, ,
[Top]

Replace JavaScript Dialogs With the New HTML Dialog Element

You know how there are JavaScript dialogs for alerting, confirming, and prompting user actions? Say you want to replace JavaScript dialogs with the new HTML dialog element.

Let me explain.

I recently worked on a project with a lot of API calls and user feedback gathered with JavaScript dialogs. While I was waiting for another developer to code the <Modal /> component, I used alert(), confirm() and prompt() in my code. For instance:

const deleteLocation = confirm('Delete location'); if (deleteLocation) {   alert('Location deleted'); }

Then it hit me: you get a lot of modal-related features for free with alert(), confirm(), and prompt() that often go overlooked:

  • It’s a true modal. As in, it will always be on top of the stack — even on top of that <div> with z-index: 99999;.
  • It’s accessible with the keyboard. Press Enter to accept and Escape to cancel.
  • It’s screen reader-friendly. It moves focus and allows the modal content to be read aloud.
  • It traps focus. Pressing Tab will not reach any focusable elements on the main page, but in Firefox and Safari it does indeed move focus to the browser UI. What’s weird though is that you can’t move focus to the “accept” or “cancel” buttons in any browser using the Tab key.
  • It supports user preferences. We get automatic light and dark mode support right out of the box.
  • It pauses code-execution., Plus, it waits for user input.

These three JavaScripts methods work 99% of the time when I need any of these functionalities. So why don’t I — or really any other web developer — use them? Probably because they look like system errors that cannot be styled. Another big consideration: there has been movement toward their deprecation. First removal from cross-domain iframes and, word is, from the web platform entirely, although it also sounds like plans for that are on hold.

With that big consideration in mind, what are alert(), confirm() and prompt() alternatives do we have to replace them? You may have already heard about the <dialog> HTML element and that’s what I want to look at in this article, using it alongside a JavaScript class.

It’s impossible to completely replace Javascript dialogs with identical functionality, but if we use the showModal() method of <dialog> combined with a Promise that can either resolve (accept) or reject (cancel) — then we have something almost as good. Heck, while we’re at it, let’s add sound to the HTML dialog element — just like real system dialogs!

If you’d like to see the demo right away, it’s here.

A dialog class

First, we need a basic JavaScript Class with a settings object that will be merged with the default settings. These settings will be used for all dialogs, unless you overwrite them when invoking them (but more on that later).

export default class Dialog { constructor(settings = {}) {   this.settings = Object.assign(     {       /* DEFAULT SETTINGS - see description below */     },     settings   )   this.init() }

The settings are:

  • accept: This is the “Accept” button’s label.
  • bodyClass: This is a CSS class that is added to <body> element when the dialog is open and <dialog> is unsupported by the browser.
  • cancel: This is the “Cancel” button’s label.
  • dialogClass: This is a custom CSS class added to the <dialog> element.
  • message: This is the content inside the <dialog>.
  • soundAccept: This is the URL to the sound file we’ll play when the user hits the “Accept” button.
  • soundOpen: This is the URL to the sound file we’ll play when the user opens the dialog.
  • template: This is an optional, little HTML template that’s injected into the <dialog>.

The initial template to replace JavaScript dialogs

In the init method, we’ll add a helper function for detecting support for the HTML dialog element in browsers, and set up the basic HTML:

init() {   // Testing for <dialog> support   this.dialogSupported = typeof HTMLDialogElement === 'function'   this.dialog = document.createElement('dialog')   this.dialog.dataset.component = this.dialogSupported ? 'dialog' : 'no-dialog'   this.dialog.role = 'dialog'      // HTML template   this.dialog.innerHTML = `   <form method="dialog" data-ref="form">     <fieldset data-ref="fieldset" role="document">       <legend data-ref="message" id="$ {(Math.round(Date.now())).toString(36)}">       </legend>       <div data-ref="template"></div>     </fieldset>     <menu>       <button data-ref="cancel" value="cancel"></button>       <button data-ref="accept" value="default"></button>     </menu>     <audio data-ref="soundAccept"></audio>     <audio data-ref="soundOpen"></audio>   </form>`    document.body.appendChild(this.dialog)    // ... }

Checking for support

The road for browsers to support <dialog> has been long. Safari picked it up pretty recently. Firefox even more recently, though not the <form method="dialog"> part. So, we need to add type="button" to the “Accept” and “Cancel” buttons we’re mimicking. Otherwise, they’ll POST the form and cause a page refresh and we want to avoid that.

<button$ {this.dialogSupported ? '' : ` type="button"`}...></button>

DOM node references

Did you notice all the data-ref-attributes? We’ll use these for getting references to the DOM nodes:

this.elements = {} this.dialog.querySelectorAll('[data-ref]').forEach(el => this.elements[el.dataset.ref] = el)

So far, this.elements.accept is a reference to the “Accept” button, and this.elements.cancel refers to the “Cancel” button.

Button attributes

For screen readers, we need an aria-labelledby attribute pointing to the ID of the tag that describes the dialog — that’s the <legend> tag and it will contain the message.

this.dialog.setAttribute('aria-labelledby', this.elements.message.id)

That id? It’s a unique reference to this part of the <legend> element:

this.dialog.setAttribute('aria-labelledby', this.elements.message.id)

The “Cancel” button

Good news! The HTML dialog element has a built-in cancel() method making it easier to replace JavaScript dialogs calling the confirm() method. Let’s emit that event when we click the “Cancel” button:

this.elements.cancel.addEventListener('click', () => {    this.dialog.dispatchEvent(new Event('cancel'))  })

That’s the framework for our <dialog> to replace alert(), confirm(), and prompt().

Polyfilling unsupported browsers

We need to hide the HTML dialog element for browsers that do not support it. To do that, we’ll wrap the logic for showing and hiding the dialog in a new method, toggle():

toggle(open = false) {   if (this.dialogSupported && open) this.dialog.showModal()   if (!this.dialogSupported) {     document.body.classList.toggle(this.settings.bodyClass, open)     this.dialog.hidden = !open     /* If a `target` exists, set focus on it when closing */     if (this.elements.target && !open) {       this.elements.target.focus()     }   } } /* Then call it at the end of `init`: */ this.toggle()

Keyboard navigation

Next up, let’s implement a way to trap focus so that the user can tab between the buttons in the dialog without inadvertently exiting the dialog. There are many ways to do this. I like the CSS way, but unfortunately, it’s unreliable. Instead, let’s grab all focusable elements from the dialog as a NodeList and store it in this.focusable:

getFocusable() {   return [...this.dialog.querySelectorAll('button,[href],select,textarea,input:not([type=&quot;hidden&quot;]),[tabindex]:not([tabindex=&quot;-1&quot;])')] }

Next, we’ll add a keydown event listener, handling all our keyboard navigation logic:

this.dialog.addEventListener('keydown', e => {   if (e.key === 'Enter') {     if (!this.dialogSupported) e.preventDefault()     this.elements.accept.dispatchEvent(new Event('click'))   }   if (e.key === 'Escape') this.dialog.dispatchEvent(new Event('cancel'))   if (e.key === 'Tab') {     e.preventDefault()     const len =  this.focusable.length - 1;     let index = this.focusable.indexOf(e.target);     index = e.shiftKey ? index-1 : index+1;     if (index < 0) index = len;     if (index > len) index = 0;     this.focusable[index].focus();   } })

For Enter, we need to prevent the <form> from submitting in browsers where the <dialog> element is unsupported. Escape will emit a cancel event. Pressing the Tab key will find the current element in the node list of focusable elements, this.focusable, and set focus on the next item (or the previous one if you hold down the Shift key at the same time).

Displaying the <dialog>

Now let’s show the dialog! For this, we need a small method that merges an optional settings object with the default values. In this object — exactly like the default settings object — we can add or change the settings for a specific dialog.

open(settings = {}) {   const dialog = Object.assign({}, this.settings, settings)   this.dialog.className = dialog.dialogClass || ''    /* set innerText of the elements */   this.elements.accept.innerText = dialog.accept   this.elements.cancel.innerText = dialog.cancel   this.elements.cancel.hidden = dialog.cancel === ''   this.elements.message.innerText = dialog.message    /* If sounds exists, update `src` */   this.elements.soundAccept.src = dialog.soundAccept || ''   this.elements.soundOpen.src = dialog.soundOpen || ''    /* A target can be added (from the element invoking the dialog */   this.elements.target = dialog.target || ''    /* Optional HTML for custom dialogs */   this.elements.template.innerHTML = dialog.template || ''    /* Grab focusable elements */   this.focusable = this.getFocusable()   this.hasFormData = this.elements.fieldset.elements.length > 0   if (dialog.soundOpen) {     this.elements.soundOpen.play()   }   this.toggle(true)   if (this.hasFormData) {     /* If form elements exist, focus on that first */     this.focusable[0].focus()     this.focusable[0].select()   }   else {     this.elements.accept.focus()   } }

Phew! That was a lot of code. Now we can show the <dialog> element in all browsers. But we still need to mimic the functionality that waits for a user’s input after execution, like the native alert(), confirm(), and prompt() methods. For that, we need a Promise and a new method I’m calling waitForUser():

waitForUser() {   return new Promise(resolve => {     this.dialog.addEventListener('cancel', () => {        this.toggle()       resolve(false)     }, { once: true })     this.elements.accept.addEventListener('click', () => {       let value = this.hasFormData ?          this.collectFormData(new FormData(this.elements.form)) : true;       if (this.elements.soundAccept.src) this.elements.soundAccept.play()       this.toggle()       resolve(value)     }, { once: true })   }) }

This method returns a Promise. Within that, we add event listeners for “cancel” and “accept” that either resolve false (cancel), or true (accept). If formData exists (for custom dialogs or prompt), these will be collected with a helper method, then returned in an object:

collectFormData(formData) {   const object = {};   formData.forEach((value, key) => {     if (!Reflect.has(object, key)) {       object[key] = value       return     }     if (!Array.isArray(object[key])) {       object[key] = [object[key]]     }     object[key].push(value)   })   return object }

We can remove the event listeners immediately, using { once: true }.

To keep it simple, I don’t use reject() but rather simply resolve false.

Hiding the <dialog>

Earlier on, we added event listeners for the built-in cancel event. We call this event when the user clicks the “cancel” button or presses the Escape key. The cancel event removes the open attribute on the <dialog>, thus hiding it.

Where to :focus?

In our open() method, we focus on either the first focusable form field or the “Accept” button:

if (this.hasFormData) {   this.focusable[0].focus()   this.focusable[0].select() } else {   this.elements.accept.focus() }

But is this correct? In the W3’s “Modal Dialog” example, this is indeed the case. In Scott Ohara’s example though, the focus is on the dialog itself — which makes sense if the screen reader should read the text we defined in the aria-labelledby attribute earlier. I’m not sure which is correct or best, but if we want to use Scott’s method. we need to add a tabindex="-1" to the <dialog> in our init method:

this.dialog.tabIndex = -1

Then, in the open() method, we’ll replace the focus code with this:

this.dialog.focus()

We can check the activeElement (the element that has focus) at any given time in DevTools by clicking the “eye” icon and typing document.activeElement in the console. Try tabbing around to see it update:

Showing the eye icon in DevTools, highlighted in bright green.
Clicking the “eye” icon

Adding alert, confirm, and prompt

We’re finally ready to add alert(), confirm() and prompt() to our Dialog class. These will be small helper methods that replace JavaScript dialogs and the original syntax of those methods. All of them call the open()method we created earlier, but with a settings object that matches the way we trigger the original methods.

Let’s compare with the original syntax.

alert() is normally triggered like this:

window.alert(message);

In our Dialog, we’ll add an alert() method that’ll mimic this:

/* dialog.alert() */ alert(message, config = { target: event.target }) {   const settings = Object.assign({}, config, { cancel: '', message, template: '' })   this.open(settings)   return this.waitForUser() }

We set cancel and template to empty strings, so that — even if we had set default values earlier — these will not be hidden, and only message and accept are shown.

confirm() is normally triggered like this:

window.confirm(message);

In our version, similar to alert(), we create a custom method that shows the message, cancel and accept items:

/* dialog.confirm() */ confirm(message, config = { target: event.target }) {   const settings = Object.assign({}, config, { message, template: '' })   this.open(settings)   return this.waitForUser() }

prompt() is normally triggered like this:

window.prompt(message, default);

Here, we need to add a template with an <input> that we’ll wrap in a <label>:

/* dialog.prompt() */ prompt(message, value, config = { target: event.target }) {   const template = `   <label aria-label="$ {message}">     <input name="prompt" value="$ {value}">   </label>`   const settings = Object.assign({}, config, { message, template })   this.open(settings)   return this.waitForUser() }

{ target: event.target } is a reference to the DOM element that calls the method. We’ll use that to refocus on that element when we close the <dialog>, returning the user to where they were before the dialog was fired.

We ought to test this

It’s time to test and make sure everything is working as expected. Let’s create a new HTML file, import the class, and create an instance:

<script type="module">   import Dialog from './dialog.js';   const dialog = new Dialog(); </script>

Try out the following use cases one at a time!

/* alert */ dialog.alert('Please refresh your browser') /* or */ dialog.alert('Please refresh your browser').then((res) => {  console.log(res) })  /* confirm */ dialog.confirm('Do you want to continue?').then((res) => { console.log(res) })  /* prompt */ dialog.prompt('The meaning of life?', 42).then((res) => { console.log(res) })

Then watch the console as you click “Accept” or “Cancel.” Try again while pressing the Escape or Enter keys instead.

Async/Await

We can also use the async/await way of doing this. We’re replacing JavaScript dialogs even more by mimicking the original syntax, but it requires the wrapping function to be async, while the code within requires the await keyword:

document.getElementById('promptButton').addEventListener('click', async (e) => {   const value = await dialog.prompt('The meaning of life?', 42);   console.log(value); });

Cross-browser styling

We now have a fully-functional cross-browser and screen reader-friendly HTML dialog element that replaces JavaScript dialogs! We’ve covered a lot. But the styling could use a lot of love. Let’s utilize the existing data-component and data-ref-attributes to add cross-browser styling — no need for additional classes or other attributes!

We’ll use the CSS :where pseudo-selector to keep our default styles free from specificity:

:where([data-component*="dialog"] *) {     box-sizing: border-box;   outline-color: var(--dlg-outline-c, hsl(218, 79.19%, 35%)) } :where([data-component*="dialog"]) {   --dlg-gap: 1em;   background: var(--dlg-bg, #fff);   border: var(--dlg-b, 0);   border-radius: var(--dlg-bdrs, 0.25em);   box-shadow: var(--dlg-bxsh, 0px 25px 50px -12px rgba(0, 0, 0, 0.25));   font-family:var(--dlg-ff, ui-sansserif, system-ui, sans-serif);   min-inline-size: var(--dlg-mis, auto);   padding: var(--dlg-p, var(--dlg-gap));   width: var(--dlg-w, fit-content); } :where([data-component="no-dialog"]:not([hidden])) {   display: block;   inset-block-start: var(--dlg-gap);   inset-inline-start: 50%;   position: fixed;   transform: translateX(-50%); } :where([data-component*="dialog"] menu) {   display: flex;   gap: calc(var(--dlg-gap) / 2);   justify-content: var(--dlg-menu-jc, flex-end);   margin: 0;   padding: 0; } :where([data-component*="dialog"] menu button) {   background-color: var(--dlg-button-bgc);   border: 0;   border-radius: var(--dlg-bdrs, 0.25em);   color: var(--dlg-button-c);   font-size: var(--dlg-button-fz, 0.8em);   padding: var(--dlg-button-p, 0.65em 1.5em); } :where([data-component*="dialog"] [data-ref="accept"]) {   --dlg-button-bgc: var(--dlg-accept-bgc, hsl(218, 79.19%, 46.08%));   --dlg-button-c: var(--dlg-accept-c, #fff); } :where([data-component*="dialog"] [data-ref="cancel"]) {   --dlg-button-bgc: var(--dlg-cancel-bgc, transparent);   --dlg-button-c: var(--dlg-cancel-c, inherit); } :where([data-component*="dialog"] [data-ref="fieldset"]) {   border: 0;   margin: unset;   padding: unset; } :where([data-component*="dialog"] [data-ref="message"]) {   font-size: var(--dlg-message-fz, 1.25em);   margin-block-end: var(--dlg-gap); } :where([data-component*="dialog"] [data-ref="template"]:not(:empty)) {   margin-block-end: var(--dlg-gap);   width: 100%; }

You can style these as you’d like, of course. Here’s what the above CSS will give you:

Showing how to replace JavaScript dialogs that use the alert method. The modal is white against a gray background. The content reads please refresh your browser and is followed by a blue button with a white label that says OK.
alert()

Showing how to replace JavaScript dialogs that use the confirm method. The modal is white against a gray background. The content reads please do you want to continue? and is followed by a black link that says cancel, and a blue button with a white label that says OK.
confirm()

Showing how to replace JavaScript dialogs that use the prompt method. The modal is white against a gray background. The content reads the meaning of life, and is followed by a a text input filled with the number 42, which is followed by a black link that says cancel, and a blue button with a white label that says OK.
prompt()

To overwrite these styles and use your own, add a class in dialogClass,

dialogClass: 'custom'

…then add the class in CSS, and update the CSS custom property values:

.custom {   --dlg-accept-bgc: hsl(159, 65%, 75%);   --dlg-accept-c: #000;   /* etc. */ }

A custom dialog example

What if the standard alert(), confirm() and prompt() methods we are mimicking won’t do the trick for your specific use case? We can actually do a bit more to make the <dialog> more flexible to cover more than the content, buttons, and functionality we’ve covered so far — and it’s not much more work.

Earlier, I teased the idea of adding a sound to the dialog. Let’s do that.

You can use the template property of the settings object to inject more HTML. Here’s a custom example, invoked from a <button> with id="btnCustom" that triggers a fun little sound from an MP3 file:

document.getElementById('btnCustom').addEventListener('click', (e) => {   dialog.open({     accept: 'Sign in',     dialogClass: 'custom',     message: 'Please enter your credentials',     soundAccept: 'https://assets.yourdomain.com/accept.mp3',     soundOpen: 'https://assets.yourdomain.com/open.mp3',     target: e.target,     template: `     <label>Username<input type="text" name="username" value="admin"></label>     <label>Password<input type="password" name="password" value="password"></label>`   })   dialog.waitForUser().then((res) => {  console.log(res) }) });

Live demo

Here’s a Pen with everything we built! Open the console, click the buttons, and play around with the dialogs, clicking the buttons and using the keyboard to accept and cancel.

So, what do you think? Is this a good way to replace JavaScript dialogs with the newer HTML dialog element? Or have you tried doing it another way? Let me know in the comments!


Replace JavaScript Dialogs With the New HTML Dialog Element originally published on CSS-Tricks. You should get the newsletter and become a supporter.

CSS-Tricks

, , , , ,
[Top]

How to Cycle Through Classes on an HTML Element

Say you have three HTML classes, and a DOM element should only have one of them at a time:

<div class="state-1"></div> <div class="state-2"></div> <div class="state-3"></div>

Now your job is to rotate them. That is, cycle through classes on an HTML element. When some event occurs, if the element has state-1 on it, remove state-1 and add state-2. If it has state-2 on it, remove that and add state-3. On the last state, remove it, and cycle back to state-1.

Example of how to Cycle Through Classes on an HTML Element. Here a large <button> with an <svg> inside cycles through state-1, state-2, and state-3 classes, turning from red to yellow to green.

It’s notable that we’re talking about 3+ classes here. The DOM has a .classList.toggle() function, even one that takes a conditional as a second parameter, but that’s primarily useful in a two-class on/off situation, not cycling through classes.

Why? There is a number of reasons. Changing a class name gives you lots of power to re-style things in the DOM, and state management like that is a cornerstone of modern web development. But to be specific, in my case, I was wanting to do FLIP animations where I’d change a layout and trigger a tween animation between the different states.

Careful about existing classes! I saw some ideas that overwrote .className, which isn’t friendly toward other classes that might be on the DOM element. All these are “safe” choices for cycling through classes in that way.

Because this is programming, there are lots of ways to get this done. Let’s cover a bunch of them — for fun. I tweeted about this issue, so many of these solutions are from people who chimed into that discussion.

A verbose if/else statement to cycle through classes

This is what I did at first to cycle through classes. That’s how my brain works. Just write out very specific instructions for exactly what you want to happen:

if (el.classList.contains("state-1")) {   el.classList.remove("state-1");   el.classList.add("state-2"); } else if (el.classList.contains("state-2")) {   el.classList.remove("state-2");   el.classList.add("state-3"); } else {   el.classList.remove("state-3");   el.classList.add("state-1"); }

I don’t mind the verbosity here, because to me it’s super clear what’s going on and will be easy to return to this code and “reason about it,” as they say. You could consider the verbosity a problem — surely there is a way to cycle through classes with less code. But a bigger issue is that it isn’t very extensible. There is no semblance of configuration (e.g. change the names of the classes easily) or simple way to add classes to the party, or remove them.

We could use constants, at least:

const STATE_1 = "state-1"; const STATE_2 = "state-2"; const STATE_3 = "state-3";  if (el.classList.contains(STATE_1)) {   el.classList.remove(STATE_1);   el.classList.add(STATE_2); } else if (el.classList.contains(STATE_2)) {   el.classList.remove(STATE_2);   el.classList.add(STATE_3); } else {   el.classList.remove(STATE_3);   el.classList.add(STATE_1); }

But that’s not wildly different or better.

RegEx off the old class, increment state, then re-add

This one comes from Tab Atkins. Since we know the format of the class, state-N, we can look for that, pluck off the number, use a little ternary to increment it (but not higher than the highest state), then add/remove the classes as a way of cycling through them:

const oldN = +/bstate-(d+)b/.exec(el.getAttribute('class'))[1]; const newN = oldN >= 3 ? 1 : oldN+1; el.classList.remove(`state-$ {oldN}`); el.classList.add(`state-$ {newN}`);

Find the index of the class, then remove/add

A bunch of techniques to cycle through classes center around setting up an array of classes up front. This acts as configuration for cycling through classes, which I think is a smart way to do it. Once you have that, you can find the relevant classes for adding and removing them. This one is from Christopher Kirk-Nielsen:

const classes = ["state-1", "state-2", "state-3"]; const activeIndex = classes.findIndex((c) => el.classList.contains(c)); const nextIndex = (activeIndex + 1) % classes.length;  el.classList.remove(classes[activeIndex]); el.classList.add(classes[nextIndex]);

Christopher had a nice idea for making the add/remove technique shorter as well. Turns out it’s the same…

el.classList.remove(classes[activeIndex]); el.classList.add(classes[nextIndex]);  // Does the same thing. el.classList.replace(classes[activeIndex], classes[nextIndex]);

Mayank had a similar idea for cycling through classes by finding the class in an array, only rather than using classList.contains(), you check the classes currently on the DOM element with what is in the array.

const states = ["state-1", "state-2", "state-3"]; const current = [...el.classList].find(cls => states.includes(cls)); const next = states[(states.indexOf(current) + 1) % states.length]; el.classList.remove(current); el.classList.add(next);

Variations of this were the most common idea. Here’s Jhey’s and here’s Mike Wagz which sets up functions for moving forward and backward.

Cascading replace statements

Speaking of that replace API, Chris Calo had a clever idea where you chain them with the or operator and rely on the fact that it returns true/false if it works or doesn’t. So you do all three and one of them will work!

 el.classList.replace("state-1", "state-2") ||  el.classList.replace("state-2", "state-3") ||  el.classList.replace("state-3", "state-1");

Nicolò Ribaudo came to the same conclusion.

Just cycle through class numbers

If you pre-configured a 1 upfront, you could cycle through classes 1-3 and add/remove them based on that. This is from Timothy Leverett who lists another similar option in the same tweet.

// Assumes a `let s = 1` upfront el.classList.remove(`state-$ {s + 1}`); s = (s + 1) % 3; el.classList.add(`state-$ {s + 1}`);

Use data-* attributes instead

Data attributes have the same specificity power, so I have no issue with this. They might actually be more clear in terms of state handling, but even better, they have a special API that makes them nice to manipulate. Munawwar Firoz had an idea that gets this down to a one-liner:

el.dataset.state = (+el.dataset.state % 3) + 1

A data attribute state machine

You can count on David Khourshid to be ready with a state machine:

const simpleMachine = {   "1": "2",   "2": "3",   "3": "1" }; el.dataset.state = simpleMachine[el.dataset.state];

You’ll almost surely want a function

Give yourself a little abstraction, right? Many of the ideas wrote code this way, but so far I’ve move it out to focus on the idea itself. Here, I’ll leave the function in. This one is from Andrea Giammarchi in which a unique function for cycling through classes is set up ahead of time, then you call it as needed:

const rotator = (classes) => ({ classList }) => {   const current = classes.findIndex((cls) => classList.contains(cls));   classList.remove(...classes);   classList.add(classes[(current + 1) % classes.length]); };  const rotate = rotator(["state-1", "state-2", "state-3"]); rotate(el);

I heard from Kyle Simpson who had this same idea, almost character for character.

Others?

There were more ideas in the replies to my original tweet, but are, best I can tell, variations on what I’ve already shared above. Apologies if I missed yours! Feel free to share your idea again in the comments here. I see nobody used a switch statements — that could be a possibility!

David Desandro went as far as recording a video, which is wonderful as it slowly abstracts the concepts further and further until it’s succinct but still readable and much more flexible:

And here’s a demo Pen with all the code for each example in there. They are numbered, so to test out another one, comment out the one that is uncommented, and uncomment another example:


How to Cycle Through Classes on an HTML Element originally published on CSS-Tricks. You should get the newsletter and become a supporter.

CSS-Tricks

, , , ,
[Top]

On Yak Shaving and md-block, a new HTML element for Markdown

Lea Verou made a Web Component for processing Markdown. Looks like there were a couple of others out there already, but I agree with Lea in that this is a good use case for the light DOM (as opposed to the shadow DOM that is normally quite useful for web components), and that’s what Lea’s does. The output is HTML so I can imagine it’s ideal you can style it on the page like any other type rather than have to deal with that shadow DOM. I still feel like the styling stories for shadow DOM all kinda suck.

The story of how it came to be is funny and highly relatable. You just want to build one simple thing and it turns out you have to do 15 other things and it takes the better part of a week.

The demos on the landing page for <md-block> shoot over to CodePen using the prefill API. Figured I’d embed one here too:

To Shared LinkPermalink on CSS-Tricks

CSS-Tricks

, , , ,
[Top]

HTML Sanitizer API

Three cheers for (draft stage) progress on a Sanitizer API! It’s gospel that you can’t trust user input. And indeed, any app I’ve ever worked on has dealt with bad actors trying to slip in and execute nefarious code somewhere it shouldn’t.

It’s the web developer’s job to clean user input before it is used again on the page (or stored, or used server-side). This is typically done with our own code or libraries that are pulled down to help. We might write a RegEx to strip anything that looks like HTML (or the like), which has the risk of bugs and those bad actors finding a way around what our code is doing.

Instead of user-land libraries or our dancing with it ourselves, we could let the browser do it:

// some function that turns a string into real nodes const untrusted_input = to_node("<em onclick='alert(1);'>Hello!</em>");  const sanitizer = new Sanitizer(); sanitizer.sanitize(untrusted_input);  // <em>Hello!</em>

Then let it continue to be a browser responsibility over time. As the draft report says:

The browser has a fairly good idea of when it is going to execute code. We can improve upon the user-space libraries by teaching the browser how to render HTML from an arbitrary string in a safe manner, and do so in a way that is much more likely to be maintained and updated along with the browser’s own changing parser implementation.

This kind of thing is web standards at its best. Spot something annoying (and/or dangerous) that tons of people have to do, and step in to make it safer, faster, and better.

To Shared LinkPermalink on CSS-Tricks

CSS-Tricks

,
[Top]

Generate a Pull Request of Static Content With a Simple HTML Form

Jamstack has been in the website world for years. Static Site Generators (SSGs) — which often have content that lives right within a GitHub repo itself — are a big part of that story. That opens up the idea of having contributors that can open pull requests to add, change, or edit content. Very useful!

Examples of this are like:

Why built with a static site approach?

When we need to build content-based sites like this, it’s common to think about what database to use. Keeping content in a database is a time-honored good idea. But it’s not the only approach! SSGs can be a great alternative because…

  • They are cheap and easy to deploy. SSGs are usually free, making them great for an MVP or a proof of concept.
  • They have great security. There is nothing to hack through the browser, as all the site contains is often just static files.
  • You’re ready to scale. The host you’re already on can handle it.

There is another advantage for us when it comes to a content site. The content of the site itself can be written in static files right in the repo. That means that adding and updating content can happen right from pull requests on GitHub, for example. Even for the non-technically inclined, it opens the door to things like Netlify CMS and the concept of open authoring, allowing for community contributions.

But let’s go the super lo-fi route and embrace the idea of pull requests for content, using nothing more than basic HTML.

The challenge

How people contribute adding or updating a resource isn’t always perfectly straightforward. People need to understand how to fork your repository, how to and where to add their content, content formatting standards, required fields, and all sorts of stuff. They might even need to “spin up” the site themselves locally to ensure the content looks right.

People who seriously want to help our site sometimes will back off because the process of contributing is a technological hurdle and learning curve — which is sad.

You know what anybody can do? Use a <form>

Just like a normal website, the easy way for people to submit a content is to fill out a form and submit it with the content they want.

What if we can make a way for users to contribute content to our sites by way of nothing more than an HTML <form> designed to take exactly the content we need? But instead of the form posting to a database, it goes the route of a pull request against our static site generator? There is a trick!

The trick: Create a GitHub pull request with query parameters

Here’s a little known trick: We can pre-fill a pull request against our repository by adding query parameter to a special GitHub URL. This comes right from the GitHub docs themselves.

Let’s reverse engineer this.

If we know we can pre-fill a link, then we need to generate the link. We’re trying to make this easy remember. To generate this dynamic data-filled link, we’ll use a touch of JavaScript.

So now, how do we generate this link after the user submits the form?

Demo time!

Let’s take the Serverless site from CSS-Tricks as an example. Currently, the only way to add a new resource is by forking the repo on GitHub and adding a new Markdown file. But let’s see how we can do it with a form instead of jumping through those hoops.

The Serverless site itself has many categories (e.g. for forms) we can contribute to. For the sake of simplicity, let’s focus on the “Resources” category. People can add articles about things related to Serverless or Jamstack from there.

The Resources page of the CSS-Tricks Serverless site. The site is primarily purple in varying shades with accents of orange. The page shows a couple of serverless resources in the main area and a list of categories in the right sidebar.

All of the resource files are in this folder in the repo.

Showing the main page of the CSS-Tricks Serverless repo in GitHub, displaying all the files.

Just picking a random file from there to explore the structure…

--- title: "How to deploy a custom domain with the Amplify Console" url: "https://read.acloud.guru/how-to-deploy-a-custom-domain-with-the-amplify-console-a884b6a3c0fc" author: "Nader Dabit" tags: ["hosting", "amplify"] ---  In this tutorial, we’ll learn how to add a custom domain to an Amplify Console deployment in just a couple of minutes.

Looking over that content, our form must have these columns:

  • Title
  • URL
  • Author
  • Tags
  • Snippet or description of the link.

So let’s build an HTML form for all those fields:

<div class="columns container my-2">   <div class="column is-half is-offset-one-quarter">   <h1 class="title">Contribute to Serverless Resources</h1>    <div class="field">     <label class="label" for="title">Title</label>     <div class="control">       <input id="title" name="title" class="input" type="text">     </div>   </div>      <div class="field">     <label class="label" for="url">URL</label>     <div class="control">       <input id="url" name="url" class="input" type="url">     </div>   </div>        <div class="field">     <label class="label" for="author">Author</label>     <div class="control">       <input id="author" class="input" type="text" name="author">     </div>   </div>      <div class="field">     <label class="label" for="tags">Tags (comma separated)</label>     <div class="control">       <input id="tags" class="input" type="text" name="tags">     </div>   </div>        <div class="field">     <label class="label" for="description">Description</label>     <div class="control">       <textarea id="description" class="textarea" name="description"></textarea>     </div>   </div>       <!-- Prepare the JavaScript function for later -->   <div class="control">     <button onclick="validateSubmission();" class="button is-link is-fullwidth">Submit</button>   </div>        </div> </div>

I’m using Bulma for styling, so the class names in use here are from that.

Now we write a JavaScript function that transforms a user’s input into a friendly URL that we can combine as GitHub query parameters on our pull request. Here is the step by step:

  • Get the user’s input about the content they want to add
  • Generate a string from all that content
  • Encode the string to format it in a way that humans can read
  • Attach the encoded string to a complete URL pointing to GitHub’s page for new pull requests

Here is the Pen:

After pressing the Submit button, the user is taken right to GitHub with an open pull request for this new file in the right location.

GitHub pull request screen showing a new file with content.

Quick caveat: Users still need a GitHub account to contribute. But this is still much easier than having to know how to fork a repo and create a pull request from that fork.

Other benefits of this approach

Well, for one, this is a form that lives on our site. We can style it however we want. That sort of control is always nice to have.

Secondly, since we’ve already written the JavaScript, we can use the same basic idea to talk with other services or APIs in order to process the input first. For example, if we need information from a website (like the title, meta description, or favicon) we can fetch this information just by providing the URL.

Taking things further

Let’s have a play with that second point above. We could simply pre-fill our form by fetching information from the URL provided for the user rather than having them have to enter it by hand.

With that in mind, let’s now only ask the user for two inputs (rather than four) — just the URL and tags.

How does this work? We can fetch meta information from a website with JavaScript just by having the URL. There are many APIs that fetch information from a website, but you might the one that I built for this project. Try hitting any URL like this:

https://metadata-api.vercel.app/api?url=https://css-tricks.com

The demo above uses that as an API to pre-fill data based on the URL the user provides. Easier for the user!

Wrapping up

You could think of this as a very minimal CMS for any kind of Static Site Generator. All you need to do is customize the form and update the pre-filled query parameters to match the data formats you need.

How will you use this sort of thing? The four sites we saw at the very beginning are good examples. But there are so many other times where might need to do something with a user submission, and this might be a low-lift way to do it.


The post Generate a Pull Request of Static Content With a Simple HTML Form appeared first on CSS-Tricks. You can support CSS-Tricks by being an MVP Supporter.

CSS-Tricks

, , , , , , ,
[Top]

Detecting Specific Text Input with HTML and CSS

Louis Lazaris breaks down some bonafide CSS trickery from Jane. The Pen shows off interactivity where:

  1. You have to press a special combination of keys on a keyboard.
  2. Then type a secret password.

From there, a special message pops up on the screen. Easily JavaScript territory, but no, this is done here entirely in HTML and CSS, which is wild.

A lot of little known features and tricks is combined here to pull this off, like HTML’s accesskey and pattern attributes, as well as :not(), :placeholder-shown, and :valid in CSS—not to mention the custom property toggle trick.

That’s… wow. And yet, look how very little code it is.

To Shared LinkPermalink on CSS-Tricks


The post Detecting Specific Text Input with HTML and CSS appeared first on CSS-Tricks. You can support CSS-Tricks by being an MVP Supporter.

CSS-Tricks

, , , ,
[Top]

Can Include (a Certain HTML element within another Certain HTML Element)

A single-serving website from Alexander Vishnyakov for testing if it’s valid to put any particular HTML element within another type of HTML element. Kinda neat to have a quick reference for this.

Some combinations feel fairly obvious: can you put a <video> inside an <input>? Uh, no. Some are trickier: can you put a <div> inside an <h1>? Nope — that one bit me this week (derp). Some are a little confusing, like <div> is an invalid child of an <ol> but a valid child of a <dl>.

Direct Link to ArticlePermalink


The post Can Include (a Certain HTML element within another Certain HTML Element) appeared first on CSS-Tricks. You can support CSS-Tricks by being an MVP Supporter.

CSS-Tricks

, , , , ,
[Top]