css collapse tree

by Laurens Maneschijn

HTML

<hr>

Idea from: <a target="_blank" href="https://iamkate.com/code/tree-views/">source</a><br>

<hr>

<h2>without css:</h2>

<ul>
  <li>
    <details open>
      <summary>Giant planets</summary>
      <ul>
        <li>
          <details>
            <summary>Gas giants</summary>
            <ul>
              <li>Jupiter</li>
              <li>Saturn</li>
            </ul>
          </details>
        </li>
        <li>
          <details open>
            <summary>Ice giants</summary>
            <ul>
              <li>Uranus</li>
              <li>Neptune</li>
            </ul>
          </details>
        </li>
      </ul>
    </details>
  </li>
</ul>

<h2>with css:</h2>

<ul class="tree">
  <li>
    <details open>
      <summary>Giant planets</summary>
      <ul>
        <li>
          <details>
            <summary>Gas giants</summary>
            <ul>
              <li>Jupiter</li>
              <li>Saturn</li>
            </ul>
          </details>
        </li>
        <li>
          <details open>
            <summary>Ice giants</summary>
            <ul>
              <li>Uranus</li>
              <li>Neptune</li>
            </ul>
          </details>
        </li>
      </ul>
    </details>
  </li>
</ul>

<hr>

<button class="btn_toggle" data-toggledebug>toggle debug</button>
<form>
<label>
	spacing: <input type="range" data-setcss="spacing" value="1.5" min="0" max="5" step="0.5">rem
</label><br>
<label>
	radius: <input type="range" data-setcss="radius" value="7" min="0" max="20" step="1">px
</label><br>
<input type="reset"></form>

CSS

.tree {
	--spacing: 1.5rem;
	--radius: 7px;
}

/* calculations below depend on box-sizing: border-box */
.tree *::before,
.tree *::after,
.tree *,
.tree {
	box-sizing: border-box;
}

.tree li {
	display: block;
	position: relative;
	padding-left: calc(2 * var(--spacing) - var(--radius) - 2px);
}
.tree li summary {
	padding-left: calc(1 * var(--radius));
}

ul.tree,
.tree ul {
	margin-left: calc(var(--radius) - var(--spacing));
	padding-left: 0;
}

/* vertical lines: */
.tree ul li {
  border-left: 2px solid #ddd;
}

.tree ul li:last-child {
  border-color: transparent;
}

/* horizontal lines: */
.tree ul li::before {
  content      : '';
  display      : block;
  position     : absolute;
  top          : calc(var(--spacing) / -2);
  left         : -2px;
  width        : calc(var(--spacing) + 2px);
  height       : calc(var(--spacing) + 1px);
  border       : solid #ddd;
  border-width : 0 0 2px 2px;
}

.tree li summary {
	line-height: calc(var(--spacing));
	font-size: calc(0.66667 * var(--spacing));
	height: calc(var(--spacing));
	overflow: hidden;
}

.tree li:not(:has(summary)) {
	font-size: calc(0.66667 * var(--spacing));
}

/* remove summary default styling: */
.tree summary {
  display: block;
  cursor: pointer;
}

.tree summary::-webkit-details-marker,
.tree summary::marker {
  display: none;
}

.tree summary:focus {
  outline: none;
}

.tree summary:focus-visible {
  outline: 1px dotted #000;
}

/* recreate summary markers: */
.tree li::after,
.tree summary::before {
  content       : '';
  display       : block;
  position      : absolute;
  top           : calc(var(--spacing) / 2 - var(--radius));
  left          : calc(var(--spacing) - var(--radius) - 1px);
  width         : calc(2 * var(--radius));
  height        : calc(2 * var(--radius));
  background    : #ddd;
  overflow      : hidden;

/*
  border-radius : 50%;
*/

}


/* open/close markers: */
.tree details > summary::before {
  content     : '+';
  z-index     : 1;
  background  : #666;
  color  ...

JavaScript

// https://youmightnotneedjquery.com/#on
function addEventListener(el, eventNames, eventHandler, selector) {
  if (!Array.isArray(eventNames)) {
  	eventNames = [eventNames];
  }
  if (selector) {
    const wrappedHandler = (e) => {
      if (e.target && e.target.matches(selector)) {
        eventHandler(e);
      }
    };
	eventNames.forEach( (eventName) => {
    	el.addEventListener(eventName, wrappedHandler);
	});
    return wrappedHandler;
  } else {
	eventNames.forEach( (eventName) => {
	    el.addEventListener(eventName, eventHandler);
	});
    return eventHandler;
  }
}

addEventListener(document.body, 'click', (e) => {
	let body = document.body;
	let button = e.target;
	if (body.classList.contains('debug')) {
		body.classList.remove('debug');
		button.classList.remove('active');
	} else {
		body.classList.add('debug');
		button.classList.add('active');
	}
}, '[data-toggledebug]');

function setCss(css){
	let id = 'setCss_style';
	let s = document.querySelector('#'+id);
	if (!s) {
		s = document.createElement('style');
		s.id = id;
		document.head.appendChild(s);
	}
	s.innerText = css;
}
function updateCss(e) {
	let el1 = document.querySelector('[data-setcss="spacing"]');
	let el2 = document.querySelector('[data-setcss="radius"]');
	setCss(`.tree { --spacing: ${el1.value}rem; --radius: ${el2.value}px; } `);
}
updateCss();


addEventListener(
	document.body,
	'change,:input,hover,click,drag,mousemove'.split(','),
	(e) => {
		updateCss(e);
		e.target.setAttribute('title', e.target.value);
	},
	'[data-setcss]'
);