Pen Settings

HTML

CSS

CSS Base

Vendor Prefixing

Add External Stylesheets/Pens

Any URLs added here will be added as <link>s in order, and before the CSS in the editor. You can use the CSS from another Pen by using its URL and the proper URL extension.

+ add another resource

JavaScript

Babel includes JSX processing.

Add External Scripts/Pens

Any URL's added here will be added as <script>s in order, and run before the JavaScript in the editor. You can use the URL of any other Pen and it will include the JavaScript from that Pen.

+ add another resource

Packages

Add Packages

Search for and use JavaScript packages from npm here. By selecting a package, an import statement will be added to the top of the JavaScript editor for this package.

Behavior

Auto Save

If active, Pens will autosave every 30 seconds after being saved once.

Auto-Updating Preview

If enabled, the preview panel updates automatically as you code. If disabled, use the "Run" button to update.

Format on Save

If enabled, your code will be formatted when you actively save your Pen. Note: your code becomes un-folded during formatting.

Editor Settings

Code Indentation

Want to change your Syntax Highlighting theme, Fonts and more?

Visit your global Editor Settings.

HTML

              
                <!DOCTYPE html>
<html>
  <meta name="charset" content="utf-8" />
<body>

<style>
  body {
    font-family: "Helvetica Neue", sans-serif;
  }

  ul {
    list-style-type: none;
    padding-left : 0;
    user-select: none;
    -webkit-user-select: none;
  }

  ul li {
    margin-bottom: 1px;
    background: rgb(240,240,240);
    padding: 3px;
    display: block;
    color: black;
  }

  ul li.selected {
    background: rgb(150,150,255);
    color: white;
  }

  tt {
    display: inline-block;
    font-size: 1.05rem;
    color: red;
    background: rgb(250,230,230);
    padding: 1px 2px;
    border: 1px solid;
    border-radius: 2px;
  }

  .test-good, .test-bad {
    font-family: monospace;
    border: 1px solid;
    border-left: 5px solid;
    padding: 2px 5px;
    margin: 1px 0;
  }
  .test-good {
    color: green;
    background: rgb(220,255,220);
  }
  .test-bad {
    color: red;
    background: rgb(255,220,220);
  }
</style>

<h1>Let's talk list selections.</h1>

<p>Rules by which the Finder seems to do it seem rather convoluted. They revolve around two items of the list:</p>
<ol>
  <li><tt>target</tt> - the item getting clicked now</li>
  <li><tt>anchor</tt> - the item that was clicked during the previous action</li>
</ol>

<p>Funny but my made-up <tt>anchor</tt> terminology seems to match the Webkit source - <a href="https://github.com/WebKit/WebKit/blob/5d2e5ba9bf14947a90b4ecfe9ef2249508968e20/Source/WebCore/html/HTMLSelectElement.cpp#L645">see here.</a></p>

<p>Effectively this means that the list selection actions are <b>stateful</b> - it is not possible to implement them just using the list of currently selected indices and the held shortcut keys, you have to carry around the index of the anchor item as well.</p>

<script id="entire">
  const MODE_SINGLE = Symbol('single');
  const MODE_CONTINUOUS = Symbol('cont');
  const MODE_DISCONTINUOUS = Symbol('discont');

  function makeSetWithRange(fromNumber, toNumber) {
    if (toNumber < fromNumber) {
      [fromNumber, toNumber] = [toNumber, fromNumber];
    }
    const numElements = toNumber - fromNumber + 1;
    const increments = Array.from(Array(numElements).keys());
    const itemsArr = increments.map((n) => fromNumber + n);
    return new Set(itemsArr);
  }

  function setUnionOf(iterator1, iterator2) {
    return new Set([...iterator1, ...iterator2]);
  }

  function listReselectModeFromEvent(event) {
    return event.metaKey ? MODE_DISCONTINUOUS : (event.shiftKey ? MODE_CONTINUOUS : MODE_SINGLE);
  }

  // If mode is "single", select only that item and reset the anchor to
  // that item
  function listReselectSingle(targetIndex) {
    return [targetIndex, new Set([targetIndex])];
  }

  // If mode is "command", change the selection state of the given item.
  // The command-selected item becomes the new anchor for the subsequent selection
  function listReselectDiscontinuous(selectedIndicesSet, targetIndex) {
    // Toggle the membership in the set and set the anchor index
    // to the clicked element
    if (selectedIndicesSet.has(targetIndex)) {
      selectedIndicesSet.delete(targetIndex);
    } else {
      selectedIndicesSet.add(targetIndex);
    }
    return [targetIndex, selectedIndicesSet];
  }

  // If the mode is "shift", there is some work to be done.
  function listReselectContinuous(selectedIndicesSet, anchorIndex, targetIndex) {
      // This is a special case. When nothing is selected shift+select
      // starts from 0 and resets the anchor to the target index.
      if (selectedIndicesSet.size === 0) {
        return [targetIndex, makeSetWithRange(0, targetIndex)]
      }

      // Unmark selected elements adjacent to the anchor in both directions,
      // and stop at the first element which is not selected.
      // Worst performance is a single linear scan of
      // the entire set.
      let [left, right] = [anchorIndex - 1, anchorIndex + 1];
      while (selectedIndicesSet.delete(left)) {
        left--;
      }
      while (selectedIndicesSet.delete(right)) {
        right++;
      }

      // Reselect the elements between the anchor and the target,
      // and add the remaining elements which already were selected.
      const freshlySelected = makeSetWithRange(anchorIndex, targetIndex);
      const union = setUnionOf(freshlySelected, selectedIndicesSet);
      return [anchorIndex, union];
  }

  // Returns the anchorIndex for the next listReselect call,
  // and a fresh Set of selected indices.
  function listReselect({selectedIndices, mode, anchorIndex, targetIndex}) {
    // Make sure we never mutate the set/Array given to us
    const selectedIndicesSet = new Set([...selectedIndices]);
    switch (mode) {
      case MODE_SINGLE:
        return listReselectSingle(targetIndex);
      case MODE_CONTINUOUS:
        return listReselectContinuous(selectedIndicesSet, parseInt(anchorIndex) || 0, targetIndex);
      case MODE_DISCONTINUOUS:
        return listReselectDiscontinuous(selectedIndicesSet, targetIndex);
      default:
        throw new Error(`Unknown mode ${mode}`);
    }
  }
</script>

<h2>Try it out</h2>

<p>Try Command+ and Shift+ selecting them.</p>
<ul id='groceries'>
  <li>Apples</li>
  <li>Oranges</li>
  <li>Cucumbers</li>
  <li>Kiwis</li>
  <li>Peaches</li>
  <li>Plums</li>
  <li>Grapes</li>
  <li>Lettuce</li>
  <li>Cabbages</li>
  <li>Aubergines</li>
  <li>Beetroots</li>
  <li>Potatoes</li>
  <li>Tomatoes</li>
  <li>Strawberries</li>
  <li>Almonds</li>
  <li>Blackberries</li>
  <li>Lemons</li>
  <li>Watermelons</li>
  <li>Zucchinis</li>
  <li>Pomelos</li>
</ul>

<script>
  const ul = document.querySelector('ul#groceries');
  const lis = ul.querySelectorAll('li');
  let anchorIndex = null; // The second bit of state carried around
  let selectedIndices = [];

  ul.addEventListener('mousedown', (event) => {
    const clickedListItem = event.target.closest('li');
    if (!clickedListItem) return;

    const allSiblings = Array.from(clickedListItem.parentNode.querySelectorAll('li'));
    const targetIndex = allSiblings.indexOf(clickedListItem);

    let selectedIndices = allSiblings.map((el, i) => {
      if (el.classList.contains("selected")) {
        return i;
      } else {
        return null;
      }
    }).filter((idxOrNull) => idxOrNull !== null);

    // Update selection state
    const mode = listReselectModeFromEvent(event);
    [anchorIndex, selectedIndices] = listReselect({
      mode,
      selectedIndices,
      anchorIndex,
      targetIndex
    });

    // Remove "selected" class on all indices
    allSiblings.map((el) => el.classList.remove('selected'));
    selectedIndices.forEach((selectedIndex) => allSiblings[selectedIndex].classList.add('selected'));
  });
</script>

<h2>HTML multiselects</h2>

<p>Multiselects work similarly. There is one important difference though -
  in Finder dragging selected items triggers drag and drop behavior,
  but multiselects support drag-select.</p>

<select multiple style='height: 5em'>
  <option>Apples</option>
  <option>Oranges</option>
  <option>Cucumbers</option>
  <option>Kiwis</option>
  <option>Peaches</option>
  <option>Plums</option>
  <option>Grapes</option>
  <option>Lettuce</option>
  <option>Cabbages</option>
  <option>Aubergines</option>
  <option>Beetroots</option>
  <option>Potatoes</option>
  <option>Tomatoes</option>
  <option>Strawberries</option>
  <option>Almonds</option>
  <option>Blackberries</option>
  <option>Lemons</option>
  <option>Watermelons</option>
  <option>Zucchinis</option>
  <option>Pomelos</option>
</select>

<h2>Observed behavior</h2>
<ol>
  <li>Click select unselects all items and makes the <tt>target</tt> selected, and sets it as the <tt>anchor</tt> for the next operation</li>
  <li>⌘+ click flips the selected state of <tt>target</tt> and sets it as <tt>anchor</tt> for the next operation</li>
  <li>⇧+ click with nothing selected selects from the first item to <tt>target</tt>, always, and sets the <tt>target</tt> to be the anchor for the next operation.</li>
  <li>⇧+ click from an existing <tt>anchor</tt> to the <tt>target</tt> makes all items between <tt>anchor</tt> and <tt>target</tt> selected, except for the <tt>anchor</tt> - it keeps the selection state.</li>
  <li>⇧+ click to an item further down the list from an unselected anchor will cause all items between anchor and target, including both, to be selected</li>
  <li>⇧+ click to an item higher up the list from an <b>selected</b> <tt>anchor</tt> will cause all items between the target and the anchor, including both, to be selected, <b>as well as an item beyound the anchor.</b>
  <li>⇧+ click to an item higher up the list from an <b>unselected</b> anchor will cause all items between the target and the anchor, including both, to be selected, <b>as well as one item further down from the anchor. 🤷‍♂️</b>
 </ol>

<p>If this seems complex or intricate: it is. UI is hard.</p>

<h2>Implementation</h2>

<p>It is possible to implement this in a functional way, as a transformation function of the following shape:</p>

<code><pre>
  selectionMode = Single | Continuous | Discontinuous
  anchorIndex = ItemIndex | nil
  selectedIndices = [ItemIndex] # A list or Set of indices of already selected items
  update(selectedIndices, anchorIndex, targetIndex, mode) => [newAnchorIndex, newSelectedIndices]
</pre></code>

<p>On subsequent calls, the <tt>anchorIndex</tt> needs to be passed along since it is one
  of the two components of state.</p>

<p>There is some HTML manipulation involved in the demo but the function is not specific to React, Stimulus
  or even something vaguely ML-like - it is fairly generic. Should make a decent Redux reducer too.</p>
</body>

<h2>Combination with disabled checkboxes</h2>

<p>If you want to have a combination with checkboxes and checkboxes can be disabled, your best bet would be
  to treat <tt>disabled</tt> checkboxes as indices that can be used for multiselection, but to not
  highlight them and to filter them out from the result before applying it to the data model. Specifically:
</p>

<code><pre>
  const mode = listReselectModeFromEvent(evt);
  [anchorIndex, selectedIndices] = listReselect({mode, anchorIndex, selectedIndices, targetIndex})
  allSiblings.map((container, i) => {
    const checkbox = container.querySelector('input[type="checkbox"]');
    if (!checkbox.disabled) {
      checkbox.checked = selectedIndices.has(i);
    }
  });
</pre></code>

Like here:

<ul id='checkboxes'>
  <li><input type="checkbox"> Apples</li>
  <li><input type="checkbox"> Oranges</li>
  <li><input type="checkbox" disabled> Cabbages (sold out)</li>
  <li><input type="checkbox"> Almonds</li>
  <li><input type="checkbox"> Blackberries</li>
  <li><input type="checkbox"> Lemons</li>
  <li><input type="checkbox"> Watermelons</li>
</ul>

<script>
  (function n() {
    const ul = document.querySelector('ul#checkboxes');
    const lis = ul.querySelectorAll('li');
    let anchorIndex = null; // The second bit of state carried around
    let selectedIndices = [];

    ul.addEventListener('mousedown', (event) => {
      const clickedListItem = event.target.closest('li');
      if (!clickedListItem) return;

      const allSiblings = Array.from(clickedListItem.parentNode.querySelectorAll('li'));
      const targetIndex = allSiblings.indexOf(clickedListItem);

      let selectedIndices = allSiblings.map((el, i) => {
        if (el.classList.contains("selected")) {
          return i;
        } else {
          return null;
        }
      }).filter((idxOrNull) => idxOrNull !== null);

      // Update selection state
      const mode = listReselectModeFromEvent(event);
      [anchorIndex, selectedIndices] = listReselect({
        mode,
        selectedIndices,
        anchorIndex,
        targetIndex
      });

      // Remove "selected" class on all indices
      allSiblings.map((el) => el.classList.remove('selected'));
      // ...set "selected" class on selected items. Also on the one with
      // the checkbox since we use the "selected" class as a state marker -
      // but that is not a requirement.
      selectedIndices.forEach((selectedIndex) => allSiblings[selectedIndex].classList.add('selected'));

      // Update checkbox state
      allSiblings.map((container, i) => {
        const checkbox = container.querySelector('input[type="checkbox"]');
        if (!checkbox.disabled) {
          checkbox.checked = selectedIndices.has(i);
        }
      });

      event.preventDefault();
    });
  })();
</script>

<h2>Tests</h2>

<p>These only test the selection state change function, not the DOM manipulation.</p>

<script>
  function numericSort(a, b) {
    return a - b;
  }

  // https://stackoverflow.com/a/46491780
  function toJsonWithSetSupport(key, value) {
    if (typeof value === 'object' && value instanceof Set) {
      return [...value].sort(numericSort);
    }
    return value;
  }

  function assertTransition(optionsForListReselect, expectedOutcome) {
    const result = listReselect(optionsForListReselect);
    const actual = JSON.stringify(result, toJsonWithSetSupport);
    const expected = JSON.stringify(expectedOutcome, toJsonWithSetSupport);
    const input = JSON.stringify(optionsForListReselect, toJsonWithSetSupport);
    const isGood = actual === expected;
    const cssClass = isGood ? 'test-good' : 'test-bad';
    const visualComparison = isGood ? `Was: ${expected}` : `Must: ${expected} was ${actual}`;
    document.write(`<div class='${cssClass}'>${input}<br />${visualComparison}</div>`);
    return result;
  }
</script>

<h3>Single selection</h3>
<script>
  assertTransition(
    {mode: MODE_SINGLE, selectedIndices: [], anchorIndex: null, targetIndex: 0},
    [0, [0]]
  );

  assertTransition(
    {mode: MODE_SINGLE, selectedIndices: [], anchorIndex: null, targetIndex: 5},
    [5, [5]]
  );

  assertTransition(
    {mode: MODE_SINGLE, selectedIndices: [5], anchorIndex: 5, targetIndex: 5},
    [5, [5]]
  );

  assertTransition(
    {mode: MODE_SINGLE, selectedIndices: [0,1,2,3], anchorIndex: null, targetIndex: 1},
    [1, [1]]
  );

  assertTransition(
    {mode: MODE_SINGLE, selectedIndices: [0,1,2,3], anchorIndex: 3, targetIndex: 1},
    [1, [1]]
  );

  assertTransition(
    {mode: MODE_SINGLE, selectedIndices: [0,1,2,3], anchorIndex: 7, targetIndex: 1},
    [1, [1]]
  );
</script>

<h3>Discontinuous selection</h3>

<script>
  assertTransition(
    {mode: MODE_DISCONTINUOUS, selectedIndices: [], anchorIndex: null, targetIndex: 1},
    [1, [1]]
  );

  assertTransition(
    {mode: MODE_DISCONTINUOUS, selectedIndices: [1], anchorIndex: null, targetIndex: 1},
    [1, []]
  );

  assertTransition(
    {mode: MODE_DISCONTINUOUS, selectedIndices: [0,1,2,3], anchorIndex: null, targetIndex: 1},
    [1, [0,2,3]]
  );

  assertTransition(
    {mode: MODE_DISCONTINUOUS, selectedIndices: [0,1,2,3], anchorIndex: null, targetIndex: 8},
    [8, [0,1,2,3,8]]
  );

  assertTransition(
    {mode: MODE_DISCONTINUOUS, selectedIndices: [0,1,2,3], anchorIndex: 0, targetIndex: 0},
    [0, [1,2,3]]
  );
</script>

<h3>Continuous selection (this is where it gets tricky)</h3>

<script>
  let mode = MODE_CONTINUOUS;
  assertTransition(
    {mode, selectedIndices: [], anchorIndex: null, targetIndex: 3},
    [3, [0,1,2,3]]
  );

  assertTransition(
    {mode, selectedIndices: [0,1,], anchorIndex: 0, targetIndex: 3},
    [0, [0,1,2,3]]
  );

  assertTransition(
    {mode, selectedIndices: [0,1,2,3], anchorIndex: 0, targetIndex: 1},
    [0, [0,1]]
  );

  assertTransition(
    {mode, selectedIndices: [0,2,5,6], anchorIndex: 0, targetIndex: 7},
    [0, [0,1,2,3,4,5,6,7]]
  );

  assertTransition(
    {mode, selectedIndices: [0,2], anchorIndex: 2, targetIndex: 4},
    [2, [0,2,3,4]]
  );

  assertTransition(
    {mode, selectedIndices: [0,1,2,3], anchorIndex: 0, targetIndex: 1},
    [0, [0,1,]]
  );

  assertTransition(
    {mode, selectedIndices: [0,1,2,5], anchorIndex: 0, targetIndex: 3},
    [0, [0,1,2,3,5]]
  );

  assertTransition(
    {mode, selectedIndices: [0,2,4], anchorIndex: 4, targetIndex: 2},
    [4, [0,2,3,4]]
  )

  // Weird behavior which happens when you Cmd+deselect an item in-between, then Cmd+reselect it
  // and then Shift+select outside that item
  assertTransition(
    {mode, selectedIndices: [2,3,4,5], anchorIndex: 3, targetIndex: 1},
    [3, [1,2,3]]
  );

</script>

<h2>Show me the code!</h2>

<p>There is less code than one would think. But it took a long wile to get to it. See here:</p>

<script>
  const scriptContents = document.querySelector("script#entire").text;
  document.write("<pre><code>" + scriptContents + "</pre></code>");
</script>

</html>

              
            
!

CSS

              
                
              
            
!

JS

              
                
              
            
!
999px

Console