StackDeck.tsx

4.8 KB · raw

1import { createMemo, createSignal, For, onMount, onCleanup } from "solid-js";2import { StackCard } from "./StackCard";3import type { CardId, StackBodies, StackCardSpec } from "./types";45interface StackDeckProps {6  active: CardId;7  onActiveChange: (id: CardId) => void;8  onBack: () => void;9  cards: readonly StackCardSpec[];10  bodies: StackBodies;11  /** Extension version string (e.g. "3.6.38"), shown next to the card counter. */12  version?: string;13}1415const EXPANDED_H = 258;16const DEFAULT_FOCUS_H = 462;17const TAB_H = 46;18const TAB_OVERLAP = 14;1920export function StackDeck(props: StackDeckProps) {21  const [deckOpen, setDeckOpen] = createSignal(true);22  const [focusHeight, setFocusHeight] = createSignal(DEFAULT_FOCUS_H);23  let focusedAt = 0;24  let deckEl: HTMLDivElement | undefined;2526  function handleBack() {27    if (!deckOpen()) {28      setDeckOpen(true);29    } else {30      props.onBack();31    }32  }3334  onMount(() => {35    const listener = () => handleBack();36    window.addEventListener("peach:settings-back", listener);3738    const updateFocusHeight = () => {39      if (!deckEl) return;40      setFocusHeight(Math.round(deckEl.getBoundingClientRect().height));41    };42    updateFocusHeight();43    const observer =44      typeof ResizeObserver === "undefined"45        ? undefined46        : new ResizeObserver(updateFocusHeight);47    if (deckEl) observer?.observe(deckEl);48    if (!observer) window.addEventListener("resize", updateFocusHeight);4950    onCleanup(() => {51      window.removeEventListener("peach:settings-back", listener);52      observer?.disconnect();53      if (!observer) window.removeEventListener("resize", updateFocusHeight);54    });55  });5657  const activeIndex = createMemo(() => {58    const idx = props.cards.findIndex((card) => card.id === props.active);59    return idx >= 0 ? idx : 0;60  });6162  return (63    <section class="stk-shell" aria-label="Settings">64      <div class="stk-mast">65        <button66          class="stk-back"67          onClick={handleBack}68          aria-label="Back"69          type="button"70        >71          <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">72            <path d="M6 1L1 6L6 11" />73          </svg>74        </button>75        <div class="stk-mast-title">Settings</div>76        <div class="stk-mast-meta">77          <span class="stk-mast-counter">{activeIndex() + 1} / {props.cards.length}</span>78          {props.version && <span class="stk-mast-version">v{props.version}</span>}79        </div>80      </div>8182      <div83        ref={(el) => { deckEl = el; }}84        class={`stk-deck ${deckOpen() ? "is-open" : "is-focused"}`}85        onWheel={(e) => {86          const cardEl = (e.target as HTMLElement).closest<HTMLElement>("[data-settings-card-id]");87          if (!cardEl || cardEl.dataset.active !== "true") return;88          if (deckOpen() && e.deltaY > 5) {89            // Scroll down on active card → unveil to focus mode90            setDeckOpen(false);91            focusedAt = Date.now();92          } else if (!deckOpen() && e.deltaY < -5 && Date.now() - focusedAt > 300) {93            // Scroll up at top of focused card → minimize back to deck94            // 300ms guard prevents fighting right after unveil95            const body = cardEl.querySelector<HTMLElement>(".stk-card-body");96            if (!body || body.scrollTop === 0) setDeckOpen(true);97          }98        }}99      >100        <For each={props.cards}>101          {(card, index) => {102            const active = () => card.id === props.active;103            const concealed = () => !deckOpen() && !active();104            const distance = createMemo(() => {105              const ai = activeIndex();106              const ci = index();107              if (ci === ai) return -1;108              if (ci > ai) return ci - ai - 1;109              return props.cards.length - ai + ci - 1;110            });111            const top = () => {112              if (active()) return 0;113              if (!deckOpen()) return focusHeight() + 44 + distance() * 8;114              return EXPANDED_H - TAB_OVERLAP + distance() * (TAB_H - TAB_OVERLAP);115            };116            const zIndex = () => (active() ? 99 : 50 - distance());117118            return (119              <StackCard120                spec={card}121                active={active()}122                focused={!deckOpen() && active()}123                concealed={concealed()}124                top={top()}125                zIndex={zIndex()}126                onClick={() => {127                  if (active()) return;128                  props.onActiveChange(card.id);129                  setDeckOpen(false);130                }}131                height={active() ? (deckOpen() ? EXPANDED_H : focusHeight()) : TAB_H + 4}132              >133                {props.bodies[card.id]}134              </StackCard>135            );136          }}137        </For>138      </div>139    </section>140  );141}