Search code examples
javascripthtmlcsscopy-pastedom-events

How do I make an <a> element with text-selectable child elements?


I inherited some markup where a series of top-level <a> elements each contain a set of <span> elements, and using CSS, they're rendered as clickable blocks in a list, like this:

.list {
  display: inline-flex;
  flex-flow: column nowrap;
  font: 14px Arial;
}
  .list a {
    display: flex;
    flex-flow: column nowrap;
    align-items: stretch;
    border: 1px solid #CCC;
    border-bottom: none;
    background: #FFF;
    padding: 4px 10px;
    text-decoration: none;
    color: #000;
  }
    .list a:last-child {
      border-bottom: 1px solid #CCC;
    }
    .list a:hover {
      background: #CDE;
    }

    .list a .name {
      font-weight: bold;
    }
    .list a .secondary {
      display: flex;
      flex-flow: row nowrap;
      justify-content: space-between;
      color: #678;
      font-size: 85%;
      padding-top: 2px;
    }
    
    .list a .address {
      padding-right: 16px;
      padding-left: 8px;
    }
    .list a .company-id {
      color: #B88;
      cursor: text;
      padding-left: 4px;
      padding-right: 4px;
      margin-right: -4px;
    }
<div class="list">
    <a href="/link/to/company/10101">
        <span class="name">Alice Jones &amp; Co.</span>
        <span class="secondary">
            <span class="address">55 Oak Street, Anytown 15151</span>
            <span class="company-id">#10101</span>
        </span>
    </a>
    <a href="/link/to/company/12345">
        <span class="name">John Smith Inc.</span>
        <span class="secondary">
            <span class="address">123 Main Street, Anytown 15151</span>
            <span class="company-id">#12345</span>
        </span>
    </a>
    <a href="/link/to/company/20123">
        <span class="name">Bob Johnson LLC</span>
        <span class="secondary">
            <span class="address">17 Spruce Street, Anytown 15152</span>
            <span class="company-id">#20123</span>
        </span>
    </a>
</div>

The Request

A product owner asked me the other day if I could make the company IDs not clickable — our users want to be able to select the text of the IDs for copy-and-paste. Fine, I thought: Turn each <a> element into an <li> like it should be anyway, add a little JavaScript to follow the links on clicks, and ignore clicks on the company IDs, and done.

Then I learned there's another user requirement — that the <a> elements must also be middle-clickable or Ctrl-clickable to open them in a new tab. I intended to tweak the JavaScript to invoke window.open() if the Ctrl-key or middle-mouse button was down, but it seems that ad-blockers and browser popup blockers get in the way of that working reliably: The <a> element needs to be a real <a> element, and its events must be left more-or-less untouched. But that means that the <a> will capture every bubbling event on its content, including events I'd prefer it not touch, like the click-and-drag (and double-click) events on the company ID.

And since the list has a flexible layout, I can't put the company ID element outside the <a> element, and then appear to make it part of the same block using position or margin tricks: The spacing won't work, because the IDs vary pretty widely in length (from 1 to 129370-5486).

tl;dr: I need a child element to exist inside an <a> element for layout — but it needs to exist outside the same element for behavior.


Requirements

For a valid solution, I have to meet these requirements:

  • The full <a> element must be clickable as a link, except for the company ID <span>.
  • The full <a> element can be middle-clicked to open it in a new tab, except for the company ID <span>.
  • A user must be able to click-and-drag on the company ID <span> to select and copy its text.
  • A user must be able to double-click on the company ID <span> to select and copy its text.
  • The layout must be flexible, allowing text spans of arbitrary length, and collapsing to the narrowest overall width.
  • The solution must work in modern evergreen browsers (i.e., Chrome, Firefox, Edge — no old-IE compatibility required!).

Beyond that, the sky's the limit: Dependencies, no dependencies, add/tweak the CSS, add some JS, change the markup — as long as those six bullet points are met, you can do whatever you want.


My Best Solution

I've tried an awful lot of JavaScript event-capturing tricks so far, most of which were failures. The best working solution I've found involves no JS at all: I include the company ID in the markup twice — once inside the <a> with visibility:hidden for layout purposes, and then again in the markup after the </a>, with a position:relative-containing <li> element around all of it, and position:absolute / bottom: / right: on the visible, selectable <span>. But it seems like there ought to be a better way that doesn't involve mutating the markup; and if the product owners ever want more text in each box, or a slightly different layout, my solution is not likely to adjust to those changes very well.

So do you have any better ideas than I have for pulling off normal, selectable text elements inside an otherwise-clickable <a> element parent?


Solution

  • Heydon Pickering wrote an article on inclusive card design where it goes over how to handle selectable text inside a card component where the entire card is clickable.

    One of the solutions that would work for you would be to change each of the <a> elements into <li> elements as you had, but then add an <a> tag around just the name of the company. Then you can add a pseudo element to the anchor tag that expands the full width/height of the parent <li>. Lastly, add position: relative to the id to bring it above the anchors pseudo element so it won't activate the link.

    .list {
      display: inline-flex;
      flex-flow: column nowrap;
      font: 14px Arial;
      margin: 0;
      padding: 0;
    }
      .list li {
        display: flex;
        flex-flow: column nowrap;
        align-items: stretch;
        border: 1px solid #CCC;
        border-bottom: none;
        background: #FFF;
        padding: 4px 10px;
        text-decoration: none;
        color: #000;
        position: relative;
      }
        .list a::after {
          content: '';
          position: absolute;
          top: 0;
          bottom: 0;
          left: 0;
          right: 0;
        }
      
        .list li:last-child {
          border-bottom: 1px solid #CCC;
        }
        .list li:hover {
          background: #CDE;
        }
    
        .list li .name {
          font-weight: bold;
        }
        .list li .secondary {
          display: flex;
          flex-flow: row nowrap;
          justify-content: space-between;
          color: #678;
          font-size: 85%;
          padding-top: 2px;
        }
        
        .list li .address {
          padding-right: 16px;
          padding-left: 8px;
        }
        .list li .company-id {
          color: #B88;
          cursor: text;
          padding-left: 4px;
          padding-right: 4px;
          margin-right: -4px;
          position: relative;
        }
    <ul class="list">
        <li>
            <a href="/link/to/company/10101" class="name">Alice Jones &amp; Co.</a>
            <span class="secondary">
                <span class="address">55 Oak Street, Anytown 15151</span>
                <span class="company-id">#10101</span>
            </span>
        </li>
        <li>
          <a href="/link/to/company/12345" class="name">John Smith Inc.</a>
          <span class="secondary">
              <span class="address">123 Main Street, Anytown 15151</span>
              <span class="company-id">#12345</span>
          </span>
        </li>
        <li>
          <a href="/link/to/company/20123" class="name">Bob Johnson LLC</a>
          <span class="secondary">
              <span class="address">17 Spruce Street, Anytown 15152</span>
              <span class="company-id">#20123</span>
          </span>
        </li>
    </ul>