SPB Git

spb/qwhpi Public

QHPI — Quebec Housing Price Index: quality-adjusted, hierarchically pooled housing price indexes.

Python 63.9% TypeScript 25.4% CSS 5.5% TeX 3.5% SQL 0.8% Makefile 0.5% Dockerfile 0.5%
4.8 KB · 147 lines tsx
Raw Blame History
1/**2 * =============================================================================3 * QWHPI — Quebec Weekly Housing Price Index4 * Author  : Simon-Pierre Boucher5 * Contact : contact@spboucher.ai6 * File    : web/components/CommandPalette.tsx7 * Purpose : ⌘K command palette — fuzzy jump to any series or page,8 *           full keyboard navigation, zero dependencies.9 * =============================================================================10 */11"use client";1213import { useCallback, useEffect, useMemo, useRef, useState } from "react";14import { useRouter } from "next/navigation";15import { fetchGeographies, type GeographyNode } from "../lib/api";1617interface Item {18  label: string;19  hint: string;20  href: string;21}2223const PAGES: Item[] = [24  { label: "Overview", hint: "page", href: "/" },25  { label: "Explore", hint: "page", href: "/explore" },26  { label: "Compare", hint: "page", href: "/compare" },27  { label: "Map", hint: "page", href: "/map" },28  { label: "Methodology", hint: "page", href: "/methodology" },29  { label: "API docs", hint: "page", href: "/api-docs" },30];31const TYPES = ["all", "unifamilial", "condo", "plex"];3233function norm(s: string): string {34  return s.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase();35}3637export default function CommandPalette() {38  const router = useRouter();39  const [open, setOpen] = useState(false);40  const [q, setQ] = useState("");41  const [sel, setSel] = useState(0);42  const [geos, setGeos] = useState<GeographyNode[]>([]);43  const inputRef = useRef<HTMLInputElement>(null);44  const listRef = useRef<HTMLUListElement>(null);4546  useEffect(() => {47    const onKey = (e: KeyboardEvent) => {48      if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {49        e.preventDefault();50        setOpen((v) => !v);51      } else if (e.key === "Escape") {52        setOpen(false);53      }54    };55    window.addEventListener("keydown", onKey);56    return () => window.removeEventListener("keydown", onKey);57  }, []);5859  useEffect(() => {60    if (open) {61      setQ("");62      setSel(0);63      setTimeout(() => inputRef.current?.focus(), 30);64      if (!geos.length)65        fetchGeographies().then((g) => setGeos(g.published_series)).catch(() => {});66    }67  }, [open, geos.length]);6869  const items = useMemo<Item[]>(() => {70    const series: Item[] = geos.flatMap((g) =>71      g.property_types.filter((t) => TYPES.includes(t)).map((t) => ({72        label: `${g.geography_name} · ${t}`,73        hint: g.geography_level,74        href: `/explore?geography=${g.geography_id}&type=${t}`,75      })));76    const all = [...PAGES, ...series];77    if (!q) return all.slice(0, 12);78    const tokens = norm(q).split(/\s+/).filter(Boolean);79    return all80      .filter((i) => {81        const hay = norm(i.label + " " + i.hint);82        return tokens.every((t) => hay.includes(t));83      })84      .slice(0, 12);85  }, [geos, q]);8687  const go = useCallback((item: Item) => {88    setOpen(false);89    router.push(item.href);90  }, [router]);9192  const onInputKey = (e: React.KeyboardEvent) => {93    if (e.key === "ArrowDown") {94      e.preventDefault();95      setSel((s) => Math.min(s + 1, items.length - 1));96    } else if (e.key === "ArrowUp") {97      e.preventDefault();98      setSel((s) => Math.max(s - 1, 0));99    } else if (e.key === "Enter" && items[sel]) {100      go(items[sel]);101    }102  };103104  useEffect(() => {105    listRef.current?.children[sel]?.scrollIntoView({ block: "nearest" });106  }, [sel]);107108  if (!open) return null;109110  return (111    <div className="palette-backdrop" onClick={() => setOpen(false)}112         role="dialog" aria-modal="true" aria-label="Command palette">113      <div className="palette" onClick={(e) => e.stopPropagation()}>114        <input115          ref={inputRef}116          value={q}117          onChange={(e) => { setQ(e.target.value); setSel(0); }}118          onKeyDown={onInputKey}119          placeholder="Jump to a series or page…  (e.g. montreal condo)"120          aria-label="Search series and pages"121          className="palette-input"122        />123        <ul ref={listRef} className="palette-list" role="listbox">124          {items.map((it, i) => (125            <li key={it.href + it.label} role="option" aria-selected={i === sel}>126              <button127                className={"palette-item" + (i === sel ? " active" : "")}128                onMouseEnter={() => setSel(i)}129                onClick={() => go(it)}130              >131                <span>{it.label}</span>132                <span className="palette-hint">{it.hint}</span>133              </button>134            </li>135          ))}136          {!items.length && (137            <li className="palette-empty">No match — try a region or city name</li>138          )}139        </ul>140        <div className="palette-foot">141          <span>↑↓ navigate</span><span>↵ open</span><span>esc close</span>142        </div>143      </div>144    </div>145  );146}147