From 26789c80d096e374e20fa677c5075e3d09da73e4 Mon Sep 17 00:00:00 2001 From: Piotr Monwid-Olechnowicz Date: Mon, 13 Oct 2025 13:27:15 +0200 Subject: [PATCH 01/49] wip --- .../checkbox-tree/checkbox-tree.tsx | 216 +++++++++ src/components/checkbox-tree/index.ts | 2 + src/components/tools-and-libraries.tsx | 411 +++++++++++------- 3 files changed, 463 insertions(+), 166 deletions(-) create mode 100644 src/components/checkbox-tree/checkbox-tree.tsx create mode 100644 src/components/checkbox-tree/index.ts diff --git a/src/components/checkbox-tree/checkbox-tree.tsx b/src/components/checkbox-tree/checkbox-tree.tsx new file mode 100644 index 0000000000..cf086d2bde --- /dev/null +++ b/src/components/checkbox-tree/checkbox-tree.tsx @@ -0,0 +1,216 @@ +import type { ReactNode } from "react" +import { useEffect, useMemo, useState } from "react" +import { clsx } from "clsx" + +import { ChevronLeftIcon } from "@/icons" + +export interface CheckboxTreeItem { + id: string + label: string + value?: string + count?: number + description?: string + children?: CheckboxTreeItem[] +} + +interface CheckboxTreeProps { + items: CheckboxTreeItem[] + selectedValues: string[] + onSelectionChange: (next: string[]) => void + searchQuery?: string + emptyFallback?: ReactNode +} + +type PreparedItem = CheckboxTreeItem & { depth: number } + +type PreparedTree = PreparedItem & { + children?: PreparedTree[] + matchesSearch: boolean + hasVisibleChildren: boolean +} + +export function CheckboxTree({ + items, + selectedValues, + onSelectionChange, + searchQuery, + emptyFallback, +}: CheckboxTreeProps) { + const normalizedSearch = searchQuery?.trim().toLowerCase() ?? "" + + const { allParentIds, defaultExpanded, preparedItems } = useMemo(() => { + const parentIds = new Set() + const defaultOpen = new Set() + + function enhance( + itemsToEnhance: CheckboxTreeItem[], + depth: number, + ): PreparedTree[] { + return itemsToEnhance.map(item => { + const prepared: PreparedTree = { + ...item, + depth, + matchesSearch: normalizedSearch + ? item.label.toLowerCase().includes(normalizedSearch) + : true, + hasVisibleChildren: false, + } + + if (item.children && item.children.length > 0) { + parentIds.add(item.id) + if (depth === 0) { + defaultOpen.add(item.id) + } + prepared.children = enhance(item.children, depth + 1) + } + + return prepared + }) + } + + return { + allParentIds: parentIds, + defaultExpanded: defaultOpen, + preparedItems: enhance(items, 0), + } + }, [items, normalizedSearch]) + + const [expandedItems, setExpandedItems] = useState( + () => new Set(defaultExpanded), + ) + + useEffect(() => { + if (!normalizedSearch) { + setExpandedItems(new Set(defaultExpanded)) + return + } + setExpandedItems(new Set(allParentIds)) + }, [normalizedSearch, allParentIds, defaultExpanded]) + + const filteredTree = useMemo(() => { + function markVisibility(node: PreparedTree): PreparedTree | null { + const { children } = node + const visibleChildren = children + ?.map(child => markVisibility(child)) + .filter((child): child is PreparedTree => Boolean(child)) + + const hasVisibleChildren = Boolean(visibleChildren?.length) + + const shouldKeepNode = + node.matchesSearch || !normalizedSearch || hasVisibleChildren + + if (!shouldKeepNode) return null + + return { + ...node, + children: visibleChildren, + hasVisibleChildren, + } + } + + return preparedItems + .map(node => markVisibility(node)) + .filter((node): node is PreparedTree => Boolean(node)) + }, [preparedItems, normalizedSearch]) + + const toggleValue = (value: string) => { + if (selectedValues.includes(value)) { + onSelectionChange(selectedValues.filter(tag => tag !== value)) + } else { + onSelectionChange([...selectedValues, value]) + } + } + + const toggleExpand = (id: string) => { + setExpandedItems(prev => { + const next = new Set(prev) + if (next.has(id)) { + next.delete(id) + } else { + next.add(id) + } + return next + }) + } + + const renderTree = (nodes: PreparedTree[]): ReactNode => { + return nodes.map(node => { + const isExpanded = expandedItems.has(node.id) + const isSelectable = Boolean(node.value) + const checkboxId = `checkbox-tree-${node.id}` + + return ( +
+
+ {node.children && node.children.length > 0 ? ( + + ) : ( + + )} + + {isSelectable ? ( + + ) : ( +
+ {node.label} + {node.description ? ( + + {node.description} + + ) : null} +
+ )} +
+ + {node.children && node.children.length > 0 && isExpanded ? ( +
{renderTree(node.children)}
+ ) : null} +
+ ) + }) + } + + if (filteredTree.length === 0) { + return ( +
+ {emptyFallback ?? "No matches"} +
+ ) + } + + return
{renderTree(filteredTree)}
+} diff --git a/src/components/checkbox-tree/index.ts b/src/components/checkbox-tree/index.ts new file mode 100644 index 0000000000..9d0602a173 --- /dev/null +++ b/src/components/checkbox-tree/index.ts @@ -0,0 +1,2 @@ +export { CheckboxTree } from "./checkbox-tree" +export type { CheckboxTreeItem } from "./checkbox-tree" diff --git a/src/components/tools-and-libraries.tsx b/src/components/tools-and-libraries.tsx index 33425021c8..a78e9d5aee 100644 --- a/src/components/tools-and-libraries.tsx +++ b/src/components/tools-and-libraries.tsx @@ -8,6 +8,7 @@ import { ChevronLeftIcon, } from "@/icons" import { Card } from "@/components" +import { CheckboxTree, type CheckboxTreeItem } from "@/components/checkbox-tree" import NextLink from "next/link" import NextHead from "next/head" import { useMounted } from "nextra/hooks" @@ -65,11 +66,13 @@ export function CodePage({ allTags, data }: CodePageProps) { const allTagsMap = useMemo( () => new Map(allTags.map(({ tag, count, name }) => [tag, { count, name }])), + // eslint-disable-next-line react-hooks/exhaustive-deps [], ) const [search, setSearch] = useState("") const [queryParamsTags, setTags] = useQueryParam("tags", TagParam) + const selectedTags = queryParamsTags as string[] const handleQuery = useCallback( (e: MouseEvent) => { @@ -91,8 +94,8 @@ export function CodePage({ allTags, data }: CodePageProps) { const inputTags = mounted && - queryParamsTags - .map(tag => [tag, allTagsMap.get(tag as string)?.name]) + selectedTags + .map(tag => [tag, allTagsMap.get(tag)?.name]) .filter(([, name]) => name) .map(([tag, name]) => ( - ) : ( - - )} - {isSelectable ? (