StackDeck.tsx

5.1 KB · raw

1import { createEffect, 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  isFanning?: boolean;14  celebratingCardId?: CardId | null;15  isPro?: boolean;16}1718const EXPANDED_H = 258;19const DEFAULT_FOCUS_H = 462;20const TAB_H = 46;21const TAB_OVERLAP = 14;2223export function StackDeck(props: StackDeckProps) {24  const [deckOpen, setDeckOpen] = createSignal(true);25  const [focusHeight, setFocusHeight] = createSignal(DEFAULT_FOCUS_H);26  let focusedAt = 0;27  let deckEl: HTMLDivElement | undefined;2829  function handleBack() {30    if (!deckOpen()) {31      setDeckOpen(true);32    } else {33      props.onBack();34    }35  }3637  createEffect(() => {38    if (props.isFanning) setDeckOpen(true);39  });4041  onMount(() => {42    const listener = () => handleBack();43    window.addEventListener("peach:settings-back", listener);4445    const updateFocusHeight = () => {46      if (!deckEl) return;47      setFocusHeight(deckEl.clientHeight || DEFAULT_FOCUS_H);48    };49    updateFocusHeight();50    const observer =51      typeof ResizeObserver === "undefined"52        ? undefined53        : new ResizeObserver(updateFocusHeight);54    if (deckEl) observer?.observe(deckEl);55    if (!observer) window.addEventListener("resize", updateFocusHeight);5657    onCleanup(() => {58      window.removeEventListener("peach:settings-back", listener);59      observer?.disconnect();60      if (!observer) window.removeEventListener("resize", updateFocusHeight);61    });62  });6364  const activeIndex = createMemo(() => {65    const idx = props.cards.findIndex((card) => card.id === props.active);66    return idx >= 0 ? idx : 0;67  });68  return (69    <section class="stk-shell" aria-label="Settings">70      <div class="stk-mast">71        <button72          class="stk-back"73          onClick={handleBack}74          aria-label="Back"75          type="button"76        >77          <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">78            <path d="M6 1L1 6L6 11" />79          </svg>80        </button>81        <div class="stk-mast-title">Settings</div>82        <div class="stk-mast-meta">83          <span class="stk-mast-counter">{activeIndex() + 1} / {props.cards.length}</span>84        </div>85      </div>8687      <div88        ref={(el) => { deckEl = el; }}89        class={`stk-deck ${deckOpen() ? "is-open" : "is-focused"} ${props.isFanning ? "is-fanning" : ""} ${props.isPro ? "is-pro" : ""}`}90        onWheel={(e) => {91          const cardEl = (e.target as HTMLElement).closest<HTMLElement>("[data-settings-card-id]");92          if (!cardEl || cardEl.dataset.active !== "true") return;93          if (deckOpen() && e.deltaY > 5) {94            // Scroll down on active card → unveil to focus mode95            setDeckOpen(false);96            focusedAt = Date.now();97          } else if (!deckOpen() && e.deltaY < -5 && Date.now() - focusedAt > 300) {98            // Scroll up at top of focused card → minimize back to deck99            // 300ms guard prevents fighting right after unveil100            const body = cardEl.querySelector<HTMLElement>(".stk-card-body");101            if (!body || body.scrollTop === 0) setDeckOpen(true);102          }103        }}104      >105        <For each={props.cards}>106          {(card, index) => {107            const active = () => card.id === props.active;108            const concealed = () => !deckOpen() && !active();109            const distance = createMemo(() => {110              const ai = activeIndex();111              const ci = index();112              if (ci === ai) return -1;113              if (ci > ai) return ci - ai - 1;114              return props.cards.length - ai + ci - 1;115            });116            const top = () => {117              if (active()) return 0;118              if (!deckOpen()) return focusHeight() + 44 + distance() * 8;119              return EXPANDED_H - TAB_OVERLAP + distance() * (TAB_H - TAB_OVERLAP);120            };121            const zIndex = () => (active() ? 99 : 50 - distance());122123            return (124              <StackCard125                spec={card}126                active={active()}127                focused={!deckOpen() && active()}128                concealed={concealed()}129                celebrating={!props.isFanning && props.celebratingCardId === card.id}130                top={top()}131                zIndex={zIndex()}132                onClick={() => {133                  if (active()) return;134                  props.onActiveChange(card.id);135                  setDeckOpen(false);136                }}137                height={active() ? (deckOpen() ? EXPANDED_H : focusHeight()) : TAB_H + 4}138              >139                {props.bodies[card.id]}140              </StackCard>141            );142          }}143        </For>144      </div>145      {props.version && <span class="stk-version-corner">v{props.version}</span>}146    </section>147  );148}