Search code examples
web-component

Custom web component that acts like a link (anchor tag)


I created a custom web component button, that has a href attribute. If one clicks on the button, I use Javascript to navigate to its own href attribute.

Unfortunately, after migrating a large code base of anchor tags that were styled to look like buttons, I have realized that my custom web component has many disadvantages:

  1. There is no right-click link context menu.
    • E.g. one can't right-click and select open in a tab.
  2. Can't CTRL+click to open link in new tab.
  3. Can't middle-click to open in a new tab.

Items 2-3 could be fixed in Javascript, but item 1 is not trivial.

I did some research about extending built-in elements using the is keyword, but that just took me down a rabbit hole of bug reports of Safari refusing to implement the web component spec for extending HTML elements.

Is there any way to add an anchor tag's behaviour to a custom web component, like via a mixin?


Solution

  • Just use @ungap/custom-elements polyfill and you're good to go with Safari as well as even Internet Explorers, both with customized built-in elements, and autonomous custom elements.

    Then an implementation could look like this:

    class CustomAnchor extends HTMLAnchorElement {
      constructor() {
        super();
        this.addEventListener('click', this.click.bind(this));
      }
      
      click(event) {
        if (event.getModifierState('Control') || event.getModifierState('Meta')) return; // allow control-click or cmd-click (mac) to work as usual
        event?.preventDefault();
        console.log(this.href);
        // do whatever you like here
      }
    }
    
    customElements.define('custom-anchor', CustomAnchor, { extends: 'a' });
    <script src="//unpkg.com/@ungap/custom-elements"></script>
    <a is="custom-anchor" href="https://google.com">Custom google anchor</a>

    The downside of extending built-ins is that there is no shadowDOM that allows style encapsulation. If you need that, instead go with an autonomous custom element and use a native anchor tag (or even your extended built-in HTMLAnchorElement) internally:

    const styles = `
      a { color: red; }
      a:hover { background-color: yellow; }
    `;
    
    class CustomAnchor extends HTMLElement {
      
      constructor() {
        super();
        this.attachShadow({mode: 'open'});
        const style = document.createElement('style');
        style.textContent = styles;
        this.anchor = document.createElement('a');
        this.shadowRoot.append(style, this.anchor);
      }
      
      static get observedAttributes() { return ['link', 'text']; }  
      
      attributeChangedCallback(attr, oldVal, newVal) {
        if (oldVal === newVal) return; // nothing changed
        switch (attr) {
          case 'link':
            if (newVal) this.anchor.href = newVal;
            else this.anchor.removeAttribute('href');
            break;
          case 'text':
            this.anchor.textContent = newVal ?? '';
            break;
          default:
        }
      }
    }
    
    customElements.define('custom-anchor', CustomAnchor);
    <script src="//unpkg.com/@ungap/custom-elements"></script>
    <custom-anchor link="https://google.com" text="Google Search"></custom-anchor>

    Context Menu - works.

    If you prefer (e.g. for screenreaders and SEO-reasons) to have the link text as the element's content, throw in a default <slot>:

    const styles = `
      a { color: red; }
      a:hover { background-color: yellow; }
    `;
    
    class CustomAnchor extends HTMLElement {
      
      constructor() {
        super();
        this.attachShadow({mode: 'open'});
        const style = document.createElement('style');
        style.textContent = styles;
        this.anchor = document.createElement('a');
        const slot = document.createElement('slot');
        this.anchor.appendChild(slot);
        this.shadowRoot.append(style, this.anchor);
      }
      
      static get observedAttributes() { return ['link']; }  
      
      attributeChangedCallback(attr, oldVal, newVal) {
        if (oldVal === newVal) return; // nothing changed
        switch (attr) {
          case 'link':
            if (newVal) this.anchor.href = newVal;
            else this.anchor.removeAttribute('href');
            break;
          default:
        }
      }
    }
    
    customElements.define('custom-anchor', CustomAnchor);
    <script src="//unpkg.com/@ungap/custom-elements"></script>
    <custom-anchor link="https://google.com">Google Search</custom-anchor>