wsb-daemon 0.275.7622

wsb's daemon: the workbench, the GitHub mirror's schedule and peer continuity, for every workspace on the machine
<!--
  One item that opens a menu of its own.

  A context menu that lists everything at the top level is one nobody reads:
  the explorer's had twenty items in one column, and the repository actions —
  the ones somebody opens it FOR — were at the bottom past three separators.
  Grouping them behind a named item keeps the first screen short, which is the
  only thing that makes the rest findable.

  It opens on hover after a short delay and on click immediately, the way a
  desktop menu does: hovering across a menu on the way to something else must
  not open every group it passes over.
-->
<script lang="ts">
  import type { Snippet } from "svelte";
  import { placeBeside } from "../../utils/menu.js";

  interface Props {
    label: string;
    /** Shown after the label — a count, usually. */
    detail?: string;
    disabled?: boolean;
    children: Snippet;
  }

  let { label, detail = "", disabled = false, children }: Props = $props();

  let open = $state(false);
  let anchor = $state<HTMLElement>();
  let panel = $state<HTMLElement>();
  let hoverTimer: ReturnType<typeof setTimeout> | undefined;

  /** Placed once it exists, and again if the window moves under it. */
  $effect(() => {
    if (open && panel && anchor) placeBeside(panel, anchor);
  });

  function openLater() {
    if (disabled) return;
    clearTimeout(hoverTimer);
    hoverTimer = setTimeout(() => (open = true), 120);
  }

  function closeLater() {
    clearTimeout(hoverTimer);
    // A moment's grace: the pointer travels from the item to the panel through
    // a pixel or two of neither, and closing on the way is how a submenu
    // becomes impossible to click.
    hoverTimer = setTimeout(() => (open = false), 180);
  }

  function keep() {
    clearTimeout(hoverTimer);
  }
</script>

<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="submenu-holder" onmouseenter={openLater} onmouseleave={closeLater}>
  <button
    class="context-menu-item submenu-item"
    bind:this={anchor}
    {disabled}
    aria-haspopup="menu"
    aria-expanded={open}
    onclick={() => (open = !open)}
  >
    <span class="submenu-label">{label}</span>
    {#if detail}<span class="submenu-detail">{detail}</span>{/if}
    <span class="submenu-caret">›</span>
  </button>

  {#if open}
    <!-- svelte-ignore a11y_no_static_element_interactions -->
    <div
      class="context-menu submenu-panel"
      bind:this={panel}
      role="menu"
      tabindex="-1"
      onmouseenter={keep}
      onmouseleave={closeLater}
      onclick={() => (open = false)}
    >
      {@render children()}
    </div>
  {/if}
</div>

<style>
  .submenu-holder {
    position: relative;
  }

  .submenu-item {
    display: flex;
    align-items: center;
    gap: var(--wb-spacing-8);
  }

  .submenu-label {
    flex: 1;
  }

  .submenu-detail,
  .submenu-caret {
    opacity: 0.6;
    font-size: var(--wb-font-size-11);
  }

  /*
    Beside the parent rather than inside it: the parent menu is portaled to the
    body and may scroll when it is taller than the window, and a submenu that
    scrolled with it would leave the item that opened it.
  */
  .submenu-panel {
    position: fixed;
    z-index: 10000;
  }
</style>