StackDeck.tsx

4.1 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 FOCUS_H = 420;17const TAB_H = 46;18const TAB_OVERLAP = 14;1920export function StackDeck(props: StackDeckProps) {21  const [deckOpen, setDeckOpen] = createSignal(true);22  let focusedAt = 0;2324  function handleBack() {25    if (!deckOpen()) {26      setDeckOpen(true);27    } else {28      props.onBack();29    }30  }3132  onMount(() => {33    const listener = () => handleBack();34    window.addEventListener("peach:settings-back", listener);35    onCleanup(() => window.removeEventListener("peach:settings-back", listener));36  });3738  const activeIndex = createMemo(() => {39    const idx = props.cards.findIndex((card) => card.id === props.active);40    return idx >= 0 ? idx : 0;41  });4243  return (44    <section class="stk-shell" aria-label="Settings">45      <div class="stk-mast">46        <button47          class="stk-back"48          onClick={handleBack}49          aria-label="Back"50          type="button"51        >52          <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">53            <path d="M6 1L1 6L6 11" />54          </svg>55        </button>56        <div class="stk-mast-title">Settings</div>57        <div class="stk-mast-meta">58          <span class="stk-mast-counter">{activeIndex() + 1} / {props.cards.length}</span>59          {props.version && <span class="stk-mast-version">v{props.version}</span>}60        </div>61      </div>6263      <div64        class={`stk-deck ${deckOpen() ? "is-open" : "is-focused"}`}65        onWheel={(e) => {66          const cardEl = (e.target as HTMLElement).closest<HTMLElement>("[data-settings-card-id]");67          if (!cardEl || cardEl.dataset.active !== "true") return;68          if (deckOpen() && e.deltaY > 5) {69            // Scroll down on active card → unveil to focus mode70            setDeckOpen(false);71            focusedAt = Date.now();72          } else if (!deckOpen() && e.deltaY < -5 && Date.now() - focusedAt > 300) {73            // Scroll up at top of focused card → minimize back to deck74            // 300ms guard prevents fighting right after unveil75            const body = cardEl.querySelector<HTMLElement>(".stk-card-body");76            if (!body || body.scrollTop === 0) setDeckOpen(true);77          }78        }}79      >80        <For each={props.cards}>81          {(card, index) => {82            const active = () => card.id === props.active;83            const concealed = () => !deckOpen() && !active();84            const distance = createMemo(() => {85              const ai = activeIndex();86              const ci = index();87              if (ci === ai) return -1;88              if (ci > ai) return ci - ai - 1;89              return props.cards.length - ai + ci - 1;90            });91            const top = () => {92              if (active()) return 0;93              if (!deckOpen()) return FOCUS_H + 44 + distance() * 8;94              return EXPANDED_H - TAB_OVERLAP + distance() * (TAB_H - TAB_OVERLAP);95            };96            const zIndex = () => (active() ? 99 : 50 - distance());9798            return (99              <StackCard100                spec={card}101                active={active()}102                focused={!deckOpen() && active()}103                concealed={concealed()}104                top={top()}105                zIndex={zIndex()}106                height={active() ? (deckOpen() ? EXPANDED_H : FOCUS_H) : TAB_H + 4}107                onClick={() => {108                  if (active()) return;109                  props.onActiveChange(card.id);110                  setDeckOpen(false);111                }}112              >113                {props.bodies[card.id]}114              </StackCard>115            );116          }}117        </For>118      </div>119    </section>120  );121}