StackDeck.tsx

3.9 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}1213const EXPANDED_H = 258;14const FOCUS_H = 420;15const TAB_H = 46;16const TAB_OVERLAP = 14;1718export function StackDeck(props: StackDeckProps) {19  const [deckOpen, setDeckOpen] = createSignal(true);20  let focusedAt = 0;2122  function handleBack() {23    if (!deckOpen()) {24      setDeckOpen(true);25    } else {26      props.onBack();27    }28  }2930  onMount(() => {31    const listener = () => handleBack();32    window.addEventListener("peach:settings-back", listener);33    onCleanup(() => window.removeEventListener("peach:settings-back", listener));34  });3536  const activeIndex = createMemo(() => {37    const idx = props.cards.findIndex((card) => card.id === props.active);38    return idx >= 0 ? idx : 0;39  });4041  return (42    <section class="stk-shell" aria-label="Settings">43      <div class="stk-mast">44        <button45          class="stk-back"46          onClick={handleBack}47          aria-label="Back"48          type="button"49        >50          <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">51            <path d="M6 1L1 6L6 11" />52          </svg>53        </button>54        <div class="stk-mast-title">Settings</div>55        <div class="stk-mast-meta">56          {activeIndex() + 1} / {props.cards.length}57        </div>58      </div>5960      <div61        class={`stk-deck ${deckOpen() ? "is-open" : "is-focused"}`}62        onWheel={(e) => {63          const cardEl = (e.target as HTMLElement).closest<HTMLElement>("[data-settings-card-id]");64          if (!cardEl || cardEl.dataset.active !== "true") return;65          if (deckOpen() && e.deltaY > 5) {66            // Scroll down on active card → unveil to focus mode67            setDeckOpen(false);68            focusedAt = Date.now();69          } else if (!deckOpen() && e.deltaY < -5 && Date.now() - focusedAt > 300) {70            // Scroll up at top of focused card → minimize back to deck71            // 300ms guard prevents fighting right after unveil72            const body = cardEl.querySelector<HTMLElement>(".stk-card-body");73            if (!body || body.scrollTop === 0) setDeckOpen(true);74          }75        }}76      >77        <For each={props.cards}>78          {(card, index) => {79            const active = () => card.id === props.active;80            const concealed = () => !deckOpen() && !active();81            const distance = createMemo(() => {82              const ai = activeIndex();83              const ci = index();84              if (ci === ai) return -1;85              if (ci > ai) return ci - ai - 1;86              return props.cards.length - ai + ci - 1;87            });88            const top = () => {89              if (active()) return 0;90              if (!deckOpen()) return FOCUS_H + 44 + distance() * 8;91              return EXPANDED_H - TAB_OVERLAP + distance() * (TAB_H - TAB_OVERLAP);92            };93            const zIndex = () => (active() ? 99 : 50 - distance());9495            return (96              <StackCard97                spec={card}98                active={active()}99                focused={!deckOpen() && active()}100                concealed={concealed()}101                top={top()}102                zIndex={zIndex()}103                height={active() ? (deckOpen() ? EXPANDED_H : FOCUS_H) : TAB_H + 4}104                onClick={() => {105                  if (active()) return;106                  props.onActiveChange(card.id);107                  setDeckOpen(false);108                }}109              >110                {props.bodies[card.id]}111              </StackCard>112            );113          }}114        </For>115      </div>116    </section>117  );118}