From a3e7c7075c6e09950fa68c45b597a6171a6d1e24 Mon Sep 17 00:00:00 2001 From: Matthew Hrehirchuk Date: Mon, 30 Dec 2024 09:44:25 -0700 Subject: [PATCH 1/2] rough prototype of sidecar search bar --- package-lock.json | 10 ++ package.json | 1 + src/components/sidecar/Sidecar.module.css | 144 +++++++++++++--------- src/components/sidecar/index.tsx | 80 ++++++++---- 4 files changed, 151 insertions(+), 84 deletions(-) diff --git a/package-lock.json b/package-lock.json index 982d8e1f..d1cb82d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@r4ai/remark-callout": "^0.6.2", "classnames": "^2.5.1", + "fuse.js": "^7.0.0", "gray-matter": "^4.0.3", "klaw-sync": "^6.0.0", "lucide-react": "^0.424.0", @@ -3587,6 +3588,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fuse.js": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.0.0.tgz", + "integrity": "sha512-14F4hBIxqKvD4Zz/XjDc3y94mNZN6pRv3U13Udo0lNLCWRBUsrMv2xwcF/y/Z5sV6+FQW+/ow68cHpm4sunt8Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=10" + } + }, "node_modules/get-intrinsic": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", diff --git a/package.json b/package.json index 418907ab..68b5a8c3 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "dependencies": { "@r4ai/remark-callout": "^0.6.2", "classnames": "^2.5.1", + "fuse.js": "^7.0.0", "gray-matter": "^4.0.3", "klaw-sync": "^6.0.0", "lucide-react": "^0.424.0", diff --git a/src/components/sidecar/Sidecar.module.css b/src/components/sidecar/Sidecar.module.css index d1a40b03..7b6b0e39 100644 --- a/src/components/sidecar/Sidecar.module.css +++ b/src/components/sidecar/Sidecar.module.css @@ -1,69 +1,99 @@ -.sidecar { - --left-padding: 12px; - --base-depth: 2; /* H1's & H2's will have standard padding */ - --depth-padding: 8px; /* Additional padding for each header number above H2 */ - --gradient-height: 20px; - --gradient-color: var(--gray-0); +.sidecarWrapper { + position: relative; + display: flex; + flex-direction: column; - &::before, - &::after { - content: ""; - position: sticky; - display: block; - left: 0; - right: 0; - height: var(--gradient-height); - pointer-events: none; - opacity: 1; - transition: opacity 0.3s ease; - z-index: 1; - } + .sidecar { + --left-padding: 12px; + --base-depth: 2; /* H1's & H2's will have standard padding */ + --depth-padding: 8px; /* Additional padding for each header number above H2 */ + --gradient-height: 20px; + --gradient-color: var(--gray-0); - &::before { - top: 0; - background: linear-gradient(to bottom, var(--gradient-color), transparent); - } + margin: 0 !important; - &::after { - bottom: 0; - background: linear-gradient(to top, var(--gradient-color), transparent); - } + &::before, + &::after { + content: ""; + position: sticky; + display: block; + left: 0; + right: 0; + height: var(--gradient-height); + pointer-events: none; + opacity: 1; + transition: opacity 0.3s ease; + z-index: 1; + } - & ul { - list-style: none; - position: relative; - z-index: 0; - & li { - --item-color: var(--gray-4); - &:hover { - --item-color: var(--gray-6); - } - &.active { - --item-color: var(--gray-8); - } - border-left: 2px solid var(--item-color); - padding: 2px 0; - padding-left: calc( - var(--left-padding) + - ( - (max(var(--depth), var(--base-depth)) - (var(--base-depth) - 1)) * - var(--depth-padding) - ) + &::before { + top: 0; + background: linear-gradient( + to bottom, + var(--gradient-color), + transparent ); - & p { - color: var(--item-color); - overflow-wrap: break-word; - } - & a { - text-decoration-line: none; - text-decoration-color: var(--item-color); + } + + &::after { + bottom: 0; + background: linear-gradient(to top, var(--gradient-color), transparent); + } + + & ul { + list-style: none; + position: relative; + z-index: 0; + & li { + --item-color: var(--gray-4); &:hover { + --item-color: var(--gray-6); + } + &.active { + --item-color: var(--gray-8); + } + border-left: 2px solid var(--item-color); + padding: 2px 0; + padding-left: calc( + var(--left-padding) + + ( + (max(var(--depth), var(--base-depth)) - (var(--base-depth) - 1)) * + var(--depth-padding) + ) + ); + & p { + color: var(--item-color); + overflow-wrap: break-word; + } + & a { + text-decoration-line: none; + text-decoration-color: var(--item-color); + &:hover { + text-decoration-line: underline; + } + } + &.active a { text-decoration-line: underline; } } - &.active a { - text-decoration-line: underline; - } + } + } + + .searchBar { + position: sticky; + top: 0; + width: 100%; + padding: 8px 4px; + margin-bottom: 8px; + border: 1px solid var(--gray-2); + border-radius: 4px; + background-color: var(--gray-0); + outline: none; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + z-index: 2; + + &:focus-within { + border-color: var(--gray-4); } } } diff --git a/src/components/sidecar/index.tsx b/src/components/sidecar/index.tsx index b203c0c7..b6d79154 100644 --- a/src/components/sidecar/index.tsx +++ b/src/components/sidecar/index.tsx @@ -3,6 +3,7 @@ import classNames from "classnames"; import { H6, P } from "../text"; import s from "./Sidecar.module.css"; import { useStore } from "@/lib/use-store"; +import Fuse from "fuse.js"; interface SidecarItem { id: string; @@ -28,15 +29,31 @@ export default function Sidecar({ items, hidden = false, }: SidecarProps) { + const [searchTerm, setSearchTerm] = useState(""); const activeItemRef = useRef(null); const sidecarRef = useRef(null); const headerIdsInView = useStore((state) => state.headerIdsInView); const shownItems = useMemo(() => { return items.filter((v) => v.depth <= MAX_SIDECAR_HEADER_DEPTH); }, [items]); + + const fuse = useMemo(() => { + return new Fuse(shownItems, { + keys: ["title"], + threshold: 0.3, // This felt pretty good + }); + }, [shownItems]); + + const filteredItems = useMemo(() => { + if (!searchTerm) return shownItems; + return fuse + .search(searchTerm) + .map((result: { item: SidecarItem }) => result.item); + }, [shownItems, searchTerm, fuse]); + const activeHeaderID = useMemo(() => { - return shownItems.find((v) => headerIdsInView.includes(v.id))?.id; - }, [shownItems, headerIdsInView]); + return filteredItems.find((v) => headerIdsInView.includes(v.id))?.id; + }, [filteredItems, headerIdsInView]); useEffect(() => { if (activeItemRef.current && sidecarRef.current) { @@ -54,35 +71,44 @@ export default function Sidecar({ }, [activeHeaderID]); return ( -
- {items.length > MIN_SIDECAR_ITEMS && !hidden && ( -
    - {items.map(({ id, title, depth }) => { - const active = id === activeHeaderID; - return ( -
  • - {/* Intentionally using an a tag and not next/link: +
    + setSearchTerm(e.target.value)} + className={s.searchBar} + /> +
    + {items.length > MIN_SIDECAR_ITEMS && !hidden && ( +
      + {filteredItems.map(({ id, title, depth }) => { + const active = id === activeHeaderID; + return ( +
    • + {/* Intentionally using an a tag and not next/link: as we want our :target selectors to trigger here. See: https://github.com/vercel/next.js/issues/51346 Also, we're remaining on the same page always here, so no client-side routing handing is needed. */} - -

      {title}

      -
      -
    • - ); - })} -
    - )} + +

    {title}

    +
    +
  • + ); + })} +
+ )} +
); } From 4b78977e20bbc21fc48f79c16adbffd074223208 Mon Sep 17 00:00:00 2001 From: Matthew Hrehirchuk Date: Sat, 2 Aug 2025 18:44:42 -0600 Subject: [PATCH 2/2] fix: sidecar search only visible when >12 items --- src/components/sidecar/index.tsx | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/components/sidecar/index.tsx b/src/components/sidecar/index.tsx index b6d79154..7f4f4d92 100644 --- a/src/components/sidecar/index.tsx +++ b/src/components/sidecar/index.tsx @@ -21,6 +21,10 @@ interface SidecarProps { // it does not make sense to have a single item in the sidecar. const MIN_SIDECAR_ITEMS = 2; +// If there are less items than this, the search bar will not render +// as it is not useful to have a search bar for less than 12 items. +const MIN_SIDECAR_SEARCH_ITEMS = 12; + // H4s and below will only display in the sidecar const MAX_SIDECAR_HEADER_DEPTH = 4; @@ -72,13 +76,15 @@ export default function Sidecar({ return (
- setSearchTerm(e.target.value)} - className={s.searchBar} - /> + {items.length > MIN_SIDECAR_SEARCH_ITEMS && !hidden && ( + setSearchTerm(e.target.value)} + className={s.searchBar} + /> + )}
{items.length > MIN_SIDECAR_ITEMS && !hidden && (