Tabs

<spicy-sections>
    <h2>Tab 1</h2>
    <div>
        <p>
            The <code>tab-bar</code> affordance
            can use shadow-dom to establish
            a styleable tab-bar and panels.
            The required parts can be exposed for styling
            with consistent pseudo-elements.
        </p>
    </div>
    <h2>Tab 2</h2>
    <div>
        <p>
            The accordion-like <code>collapse</code> affordance
            also uses shadow-dom to establish
            summary-details like behavior for each section,
            with an option to make the toggle-states
            <code>exclusive-collapse</code>.
        </p>
    </div>
    <h2>Tab 3</h2>
    <div>
        <p>
            The fallback state is simply structured HTML,
            without any show/hide affordances.
            This does not require any special styling.
        </p>
    </div>
</spicy-sections>
  • Content:
    /**
     * This work is licensed under the W3C Software and Document License
     * (http://www.w3.org/Consortium/Legal/2015/copyright-software-and-document).
     */
    class MediaAffordancesElement extends HTMLElement {
      constructor() {
        super();
    
        this.mqls = [];
        this.observers = [];
        this.supportedAffordances = new Set();
      }
    
      observeAffordanceChange(cb) {
        this.observers.push(cb);
      }
    
      notifyChange() {
        let intersection = new Set();
    
        for (let elem of this.mqls) {
          if (elem.matches && this.supportedAffordances.has(elem.__affordance)) {
            intersection.add(elem.__affordance);
          }
        }
    
        let arr = [...intersection];
    
        if (arr.length > 0) {
          this.setAttribute('mq-matched', arr.join(' '));
        } else {
          this.removeAttribute('mq-matched');
        }
    
        let affordance = arr[0];
    
        if (affordance) {
          this.setAttribute('affordance', affordance);
        } else {
          this.removeAttribute('affordance');
        }
    
        this.observers.forEach((cb) => {
          cb(intersection, this.__matching);
        });
      }
    
      static get observedAttributes() {
        return ['mq-affordances'];
      }
    
      connectedCallback() {
        let newValue = getComputedStyle(this).getPropertyValue(
          '--const-mq-affordances'
        );
    
        this.connectListeners(newValue);
      }
    
      connectListeners(newValue = '') {
        if (newValue.trim().length === 0) {
          return;
        }
    
        newValue.split('|').forEach((segment) => {
          let mq = segment.trim().match(/\[([^\]]*)/)[1];
          let names = segment.replace(`[${mq}]`, '').trim().split(' ');
          let mql = window.matchMedia(mq);
    
          mql.__affordance = names[0]; // one for now
          mql.onchange = () => this.notifyChange();
    
          this.mqls.push(mql);
        }, this);
    
        this.notifyChange();
      }
    
      attributeChangedCallback(name, oldValue, newValue) {
        this.connectListeners(newValue);
      }
    }
    
    // ----------------------------------------------------
    
    (() => {
      let lastUId = 0;
    
      let nextUId = () => {
        return `cp${++lastUId}`;
      };
    
      let getLabels = (regionset) => {
        return [...regionset.children].filter(
          (el) => /^H\d$/.test(el.tagName) || el.tagName === 'SPICY-H'
        );
      };
    
      let getContentEls = (regionset) => {
        return [...regionset.children].filter((el) => !/^H\d$/.test(el.tagName));
      };
    
      let ensureId = (el) => {
        el.id = el.id || nextUId();
        return el.id;
      };
    
      let style = document.createElement('style');
      style.innerHTML = `
    	:where(spicy-sections > [affordance*="collapse"])::before { 
    		content: ' ';
    		display: inline-block;
    		width: 0.5em;
    		height: 0.75em;
    		margin: 0 0.4em 0 0;
    		transform: rotate(90deg);
    		background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px' width='10px' height='10px' viewBox='0 0 270 240' enable-background='new 0 0 270 240' xml:space='preserve'%3e%3cpolygon fill='black' points='5,235 135,10 265,235 '/%3e%3c/svg%3e ");
    		background-size: 100% 100%;
    	} 
    	:where(spicy-sections > .hide) {
    		display: none !important;
    	} 
    	:where(spicy-sections > [affordance*="collapse"][aria-expanded="true"])::before, 
    	:where(spicy-sections > [affordance*="collapse"][aria-expanded="true"])::after {
    		transform: rotate(180deg);
    	}
    	`;
    
      document.head.prepend(style);
    
      const template = `
    	<style>
    		:root {
    			font-size: 1rem;
    		}
    		:host([hidden]),
    		::slotted([hidden]) {
    			display: none;
    		}
    		
    		::slotted(spicy-h) {
    			display: block;
    		}
    		::slotted(h1),
    		::slotted(h2),
    		::slotted(h3),
    		::slotted(h4),
    		::slotted(h5),
    		::slotted(h6),
    		::slotted(spicy-h){
    			margin-right: 1rem;
    		}
    		[part="tab-bar"] ::slotted([tabindex="0"]) {
    			border-bottom: 1px solid blue;
    		}
    		.hide {
    			display: none;
    		}
    		
    		[part="tab-list"] { 
    			display: flex; 
    			overflow: hidden;
    			white-space: nowrap;
    		}
    	</style>
    	<div part="tab-bar">
    		<!-- The region/tablist should have a label -->
    		<div part="tab-list" role="tablist"><slot name="tabListSlot"></slot></div>
    	</div>
    	<content part="content-panels">
    		<slot default></slot>
    	</content>
    	`;
    
      class RegionSet extends MediaAffordancesElement {
        __defaults;
        __tabListEl;
    
        // tabs and exclusive collapses should have the same affordance object?
        __affordanceConf = {
          collapse: {
            // can take a condition to force, check-like
            toggle: (label, condition) => {
              let state =
                typeof condition === 'boolean'
                  ? condition
                  : !label.affordanceState.expanded;
    
              let contentEl = label.nextElementSibling;
    
              label.affordanceState.expanded = state;
              label.affordanceState.nonExclusiveExpanded = state;
    
              label.setAttribute('aria-expanded', state);
    
              if (state) {
                label.setAttribute('expanded', '');
    
                contentEl.classList.remove('hide');
              } else {
                label.removeAttribute('expanded');
    
                contentEl.classList.add('hide');
              }
            },
          },
          'exclusive-collapse': {
            // ignores condition, radio-like
            toggle: (label) => {
              let labels = getLabels(label.parentElement);
              let siblings = labels.filter((c) => c !== label);
              let index = labels.findIndex((c) => c === label);
    
              siblings.forEach((sibLabel, i) => {
                let relatedContent = sibLabel.nextElementSibling;
    
                relatedContent.classList.add('hide');
    
                sibLabel.tabIndex = -1;
                sibLabel.setAttribute('aria-expanded', 'false');
                sibLabel.affordanceState.exclusiveExpanded = false;
              });
    
              label.tabIndex = 0;
              label.parentElement.affordanceState.exclusiveSelection.index = index; // TODO: nope, fix/remove this?
              label.nextElementSibling.classList.remove('hide');
              label.setAttribute('aria-expanded', 'true');
              label.affordanceState.exclusiveExpanded = true;
              label.focus();
            },
          },
          'tab-bar': {
            // ignores condition, radio-like
            toggle: (label) => {
              let labels = getLabels(label.parentElement);
              let siblings = labels.filter((c) => c !== label);
              let index = labels.findIndex((c) => c === label);
    
              siblings.forEach((sibLabel, i) => {
                let relatedContent = sibLabel.nextElementSibling;
    
                relatedContent.classList.add('hide');
    
                sibLabel.tabIndex = -1;
                sibLabel.setAttribute('aria-selected', 'false');
                sibLabel.affordanceState.exclusiveExpanded = false;
              });
    
              label.tabIndex = 0;
              label.parentElement.affordanceState.exclusiveSelection.index = index;
              label.nextElementSibling.classList.remove('hide');
              label.setAttribute('aria-selected', 'true');
              label.affordanceState.exclusiveExpanded = true;
              label.focus();
            },
          },
        };
    
        __setSize = (labelEls, contentEls) => {
          this.__size = Math.min(labelEls.length, contentEls.length);
    
          if (labelEls.length !== this.__size) {
            console.warn('mismatch in tab-set label/content pairs...');
          }
    
          labelEls.forEach((labelEl, i) => {
            let contentEl = contentEls[i];
    
            if (!labelEl.initialized) {
              labelEl.initialized = true; // TODO: this used to be shadow, do i need it?
    
              let defs = this.__defaults.defaultActive;
    
              // this assumes it is about collapses
              labelEl.affordanceState = {
                expanded: defs.includes(labelEl),
                active: false,
                // activate in the current mode
                activate: () => {
                  if (this.affordanceState.current) {
                    this.__affordanceConf[this.affordanceState.current].toggle(
                      labelEl
                    );
                  }
                },
              };
    
              let defaultExclusive =
                defs.length === 0 ? labelEls[0] : defs[defs.length - 1];
    
              this.affordanceState.exclusiveSelection.index =
                labelEls.indexOf(defaultExclusive);
            }
            labelEl.setMode = (mode) => {
              if (mode === 'non-exclusive') {
                let isExpanded = labelEl.affordanceState.expanded;
    
                labelEl.setAttribute('affordance', 'collapse');
                labelEl.setAttribute('tabindex', '0');
                labelEl.setAttribute('aria-controls', contentEl.id);
                labelEl.setAttribute('role', 'button');
                labelEl.setAttribute('aria-expanded', isExpanded);
    
                labelEl.nextElementSibling.classList.toggle('hide', !isExpanded);
              } else if (mode === 'exclusive') {
                let isExpanded =
                  labelEls.indexOf(labelEl) ===
                  this.affordanceState.exclusiveSelection.index;
    
                labelEl.setAttribute('affordance', 'collapse');
                labelEl.setAttribute('tabindex', isExpanded ? 0 : -1);
                labelEl.setAttribute('role', 'button');
                labelEl.setAttribute('aria-expanded', isExpanded);
                labelEl.setAttribute('aria-controls', contentEl.id);
    
                labelEl.nextElementSibling.classList.toggle('hide', !isExpanded);
              } else {
                labelEl.removeAttribute('tabIndex');
                labelEl.removeAttribute('affordance');
                labelEl.removeAttribute('aria-expanded');
                labelEl.removeAttribute('role');
              }
            };
          });
        };
    
        __projectTabBar = () => {
          this.__removeProjections();
    
          getLabels(this).forEach((tabSource, i) => {
            let selected = false;
            let tabIndex = -1;
    
            tabSource.setMode();
            tabSource.slot = 'tabListSlot';
            tabSource.setAttribute('role', 'tab');
    
            let contentSource = tabSource.nextElementSibling;
    
            contentSource.tabIndex = 0;
    
            tabSource.setAttribute('aria-controls', ensureId(contentSource));
    
            contentSource.setAttribute('role', 'tabpanel');
            contentSource.setAttribute('aria-labelledby', tabSource.id);
    
            if (i === this.affordanceState.exclusiveSelection.index) {
              tabIndex = 0;
              selected = true;
            }
    
            tabSource.setAttribute('aria-selected', selected);
            tabSource.tabIndex = tabIndex;
    
            contentSource.classList.toggle('hide', !selected);
    
            // TODO: aria-orientation :(
          });
        };
    
        __projectCollapses = (exclusive) => {
          // TODO: remove projections and... ??
          this.__removeProjections();
    
          getLabels(this).forEach((label) => {
            label.setMode(exclusive ? 'exclusive' : 'non-exclusive');
          });
        };
    
        __removeProjections = () => {
          Array.from(this.children, (child) => {
            child.removeAttribute('slot');
            child.removeAttribute('affordance');
            child.removeAttribute('role');
            child.removeAttribute('aria-selected');
            child.removeAttribute('aria-controls');
            child.removeAttribute('tabindex');
            child.removeAttribute('aria-expanded');
    
            child.classList.remove('hide');
          });
        };
    
        // matching pairs
        __size = 0;
    
        __configure = () => {
          // hmmm
    
          this.__setSize(getLabels(this), getContentEls(this));
    
          if (this.affordanceState.current === 'tab-bar') {
            this.affordanceState.currentMode = 'exclusive';
            this.__projectTabBar();
          } else if (this.affordanceState.current === 'collapse') {
            this.affordanceState.currentMode = 'non-exclusive';
            this.__projectCollapses();
          } else if (this.affordanceState.current === 'exclusive-collapse') {
            this.affordanceState.currentMode = 'exclusive';
            this.__projectCollapses(true);
          } else {
            this.affordanceState.currentMode = undefined;
            this.__removeProjections();
          }
    
          // TODO: hmm, these are DOM changes, we could cache them
          let labelEls = getLabels(this);
    
          for (let i = 0; i < this.__size; i++) {
            let label = labelEls[i];
    
            // probably add one handler that decides
            if (!label._inited) {
              label.addEventListener('click', (evt) => {
                evt.target.affordanceState.activate();
              });
    
              label._inited = true;
            }
          }
        };
    
        __childListObserver = new MutationObserver((mutationList) => {
          // we have to wire up new elements
          let labelEls = getLabels(this);
          let contentEls = getContentEls(this);
    
          // what if there is a mismatch?
          this.__setSize(labelEls, contentEls);
          this.__configure();
        });
    
        __tabset;
    
        affordanceState = {
          exclusiveSelection: { index: undefined },
          current: undefined,
          currentMode: undefined,
          getLabels: () => {
            return getLabels(this);
          },
        };
    
        honourFragmentLink = () => {
          let labels = getLabels(this);
    
          if (location.hash && this.querySelector(location.hash)) {
            // try to find a label with this ID, or controlled content
            // that contains an element with this ID
            for (let i = 0; i < labels.length; i++) {
              let relevantContent =
                labels[i].getAttribute('aria-controls') &&
                this.querySelector(`#${labels[i].getAttribute('aria-controls')}`);
    
              if (
                labels[i] === this.querySelector(location.hash) ||
                (relevantContent && relevantContent.querySelector(location.hash))
              ) {
                labels[i].affordanceState.activate();
    
                return;
              }
            }
          }
        };
    
        // wires up supported affordances
        constructor() {
          super();
    
          this.supportedAffordances.add('tab-bar');
          this.supportedAffordances.add('collapse');
          this.supportedAffordances.add('exclusive-collapse');
    
          let checkDefaults = () => {
            if (!this.__defaults) {
              this.__defaults = {
                onMatch: this.hasAttribute('defaults-on-match'),
                defaultActive: getLabels(this).filter((l) =>
                  l.hasAttribute('default-activate')
                ),
              };
            }
          };
    
          this.observeAffordanceChange((matching, all) => {
            if (!this.__defaults) {
              this.__defaults = {
                onMatch: this.hasAttribute('defaults-on-match'),
                defaultActive: getLabels(this).filter((l) =>
                  l.hasAttribute('default-activate')
                ),
              };
            }
    
            this.affordanceState.current = this.getAttribute('affordance');
            this.__configure();
          });
    
          this.setActiveAffordance = (matching, all) => {
            checkDefaults();
    
            this.setAttribute('affordance', matching);
    
            this.affordanceState.current = matching;
    
            this.__configure();
          };
    
          this.attachShadow({ mode: 'open' });
    
          this.shadowRoot.innerHTML = template;
    
          this.__tabListEl = this.shadowRoot.querySelector("[part='tab-list']");
    
          this.addEventListener(
            'keydown',
            (evt) => {
              let labels = getLabels(this);
              let size = labels.length;
              let cur = this.affordanceState.exclusiveSelection.index;
              let prev = cur === 0 ? size - 1 : cur - 1;
              let next = cur === size - 1 ? 0 : cur + 1;
    
              // don't trap nested handling
              if (evt.target.parentElement !== evt.currentTarget) {
                return;
              }
    
              if (
                this.affordanceState.current === 'tab-bar' ||
                this.affordanceState.current === 'exclusive-collapse'
              ) {
                if (evt.key === 'ArrowLeft' || evt.key === 'ArrowUp') {
                  labels[prev].affordanceState.activate();
                  evt.preventDefault();
                } else if (evt.key === 'ArrowRight' || evt.key === 'ArrowDown') {
                  labels[next].affordanceState.activate();
                  evt.preventDefault();
                }
              } else if (
                evt.key === ' ' &&
                this.affordanceState.current === 'collapse'
              ) {
                evt.preventDefault();
              }
            },
            false
          );
    
          this.addEventListener('keyup', (evt) => {
            if (evt.key === ' ' && this.affordanceState.current === 'collapse') {
              evt.target.closest('[affordance]').affordanceState.activate();
    
              evt.preventDefault();
            }
          });
        }
    
        connectedCallback() {
          super.connectedCallback();
    
          // TODO: handle selection
          if (location.hash) {
            setTimeout(this.honourFragmentLink, 1);
          }
    
          window.addEventListener('hashchange', this.honourFragmentLink);
    
          // if you append a fragment with a pair, it should work
          this.__childListObserver.observe(this, { childList: true });
        }
      }
    
      customElements.define('spicy-sections', RegionSet);
    })();
    
  • URL: /components/raw/tabs/SpicySections.js
  • Filesystem Path: components/02-components/tabs/SpicySections.js
  • Size: 16.7 KB
  • Content:
    p {
      margin: 0;
    }
    
    .dropdown {
      width: fit-content;
      display: inline-block;
    }
    
    .dropdown-content {
      display: none;
      position: absolute;
      background-color: var(--purple-800-brand);
      border-radius: 6px;
      min-width: 160px;
      box-shadow: 0px 3px 6px rgba(0, 0, 0, 0.25);
      z-index: 1;
    }
    
    .dropdown-content a {
      float: none;
      color: var(--white);
      padding: .5em;
      text-decoration: none;
      display: block;
      text-align: left;
      font-size: 18px;
    }
    
    .dropdown-content a:hover {
      background-color: var(--white);
      color: var(--purple-800-brand);
      border-radius: 6px;
    }
    
    .dropdown:hover .dropdown-content {
      display: block;
    }
    
    .nav-drop-gray .dropdown-content {
      background-color: var(--white);
    }
    
    .nav-drop-gray .dropdown-content a{
      color: var(--text-color);
    }
    
    .nav-drop-gray .dropdown-content a:hover {
      background-color: var(--gray-200);
    }
    
    .nav-drop-primary .dropdown-content {
      background-color: var(--white);
    }
    
    .nav-drop-primary .dropdown-content a {
      color: var(--purple-800-brand);
    }
    
    .nav-drop-primary .dropdown-content a:hover {
      background-color: var(--purple-800-brand);
      color: var(--white);
    }
    
    .nav-drop-primary .btn-outline {
      border-color: var(--white);
    }
    
    .nav-drop-primary .btn-outline:hover, .nav-drop-primary .btn-outline:focus {
      background-color: var(--white);
      color: var(--purple-800-brand);
      border-color: var(--purple-800-brand);
    }
    
    details {
      font-size: 18px;
      border: 2px solid var(--purple-800-brand);
      border-radius: 6px;
      width: 250px;
    }
    
    summary {
      background-color: var(--purple-800-brand);
      color: var(--white);
      padding: .5em;
    }
    
    .accordion-content p, .faq-content p {
      padding: .5em;
    }
    
    .faq {
      border: none;
    }
    
    .faq summary {
      background-color: var(--white);
      color: var(--text-color);
    }
    
    .faq-content {
      border: 2px solid var(--text-color);
      border-radius: 6px;
    }
    
    spicy-sections {
      /* Big hand-wave over how we'd ultimately express this, but for this custom element, this is how you inform when you'd like to emply which affodances 'collapse', 'tab-bar' and 'exclusive-collapse' are the available affordances.  Anything else is, effectively "plain" or "none". It is only read once.
      */
      --const-mq-affordances: [screen and (max-width: 40em)] collapse | [screen and (min-width: 60em)] tab-bar;
      display: block;
      padding: 1em;
    }
    
    /* just normal css/dom any heading works */
    h2 {
      margin: 0;
      font-size: 18px;
    }
    
    /* you can tell there are none employed */
    h2:not([affordance] *) {
      margin-bottom: 0.5em;
    }
    
    /* content panels when plain */
    h2:not([affordance] *):not(:first-child) {
      margin-top: 1.5em;
    }
    
    /* content panels, always */
    spicy-sections h2+* {
      margin-top: 0;
    }
    
    /* accordion buttons */
    [affordance="collapse"] {
      --heading-size: 1em;
    }
    
    /* the pseudo element created around the tabs, when it exists */
    ::part(tab-list) {
      display: flex;
    }
    
    /* only when they're tabs */
    [affordance="tab-bar"] h2 {
      padding: 0.5em;
      cursor: pointer;
      border: 2px solid var(--white);
    }
    
    [affordance="tab-bar"] h2:hover {
      border-radius: 6px 6px 0 0;
      border-color: var(--purple-800-brand);
    }
    
    /* Only when they're tabs and selected */
    [affordance="tab-bar"] h2[tabindex="0"] {
      background-color: var(--purple-800-brand);
      border-radius: 6px 6px 0 0;
      color: var(--white);
      border: 2px solid var(--purple-800-brand);
    }
    
    /* Tabs that aren't selected */
    [affordance="tab-bar"] h2:not([tabindex="0"]) {
      color: var(--purple-800-brand);
    }
    
    /* content, when it's tabs */
    [affordance="tab-bar"] [role="tabpanel"] {
      border: 2px solid var(--purple-800-brand);
      padding: .5em;
      border-radius: 6px;
      font-size: 18px;
      color: var(--text-color);
      width: 300px;
      position: relative;
      margin-top: -0.25em;
      background-color: white;
    }
  • URL: /components/raw/tabs/dropdowns.css
  • Filesystem Path: components/02-components/tabs/dropdowns.css
  • Size: 3.7 KB

Usage Guidelines

  • Use tabs to alternate between views within the same context
  • Logically chunk the content behind the tabs so users can easily predict what they’ll find when they select a given tab
  • Use sentence case
  • Use concise, plain language tab labels