@jcoder-stack/registry 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,41 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "tree",
4
+ "title": "Tree",
5
+ "description": "Generic hierarchical tree with expand/collapse and optional checkable rows; cascade logic is left to consumers via pure helper functions.",
6
+ "dependencies": [
7
+ "lucide-react",
8
+ "@jcoder-stack/abp-react",
9
+ "radix-ui"
10
+ ],
11
+ "registryDependencies": [
12
+ "checkbox"
13
+ ],
14
+ "files": [
15
+ {
16
+ "path": "ui/blocks/tree/tree.tsx",
17
+ "content": "\"use client\";\n\nimport { useLocalization } from \"@jcoder-stack/abp-react/react\";\nimport { ChevronRightIcon } from \"lucide-react\";\nimport { useId, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport type { TreeNode } from \"./tree-helpers\";\nimport { TriStateCheckbox } from \"./tri-state-checkbox\";\n\nexport interface TreeProps {\n nodes: TreeNode[];\n defaultExpanded?: string[];\n checkable?: boolean;\n checked?: Set<string>;\n indeterminate?: Set<string>;\n onCheckChange?: (id: string, checked: boolean) => void;\n}\n\nconst EMPTY_SET: Set<string> = new Set();\n\n/**\n * 通用树形块:零业务知识,只负责层级渲染/展开收起/勾选态展示与上报。\n * 级联策略(勾子强制父链、去父清子树等)不在此处实现,由消费方基于 tree-helpers 的\n * 纯函数(collectSubtreeIds/findParentChain/deriveIndeterminate)自行组合。\n */\nexport function Tree({\n nodes,\n defaultExpanded,\n checkable = false,\n checked = EMPTY_SET,\n indeterminate = EMPTY_SET,\n onCheckChange,\n}: TreeProps) {\n const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set(defaultExpanded));\n\n function toggleExpanded(id: string) {\n setExpandedIds((prev) => {\n const next = new Set(prev);\n if (next.has(id)) {\n next.delete(id);\n } else {\n next.add(id);\n }\n return next;\n });\n }\n\n return (\n <div data-slot=\"tree\">\n {nodes.map((node) => (\n <TreeRow\n key={node.id}\n node={node}\n depth={0}\n expandedIds={expandedIds}\n onToggleExpanded={toggleExpanded}\n checkable={checkable}\n checked={checked}\n indeterminate={indeterminate}\n onCheckChange={onCheckChange}\n />\n ))}\n </div>\n );\n}\n\nfunction TreeRow({\n node,\n depth,\n expandedIds,\n onToggleExpanded,\n checkable,\n checked,\n indeterminate,\n onCheckChange,\n}: {\n node: TreeNode;\n depth: number;\n expandedIds: Set<string>;\n onToggleExpanded: (id: string) => void;\n checkable: boolean;\n checked: Set<string>;\n indeterminate: Set<string>;\n onCheckChange?: (id: string, checked: boolean) => void;\n}) {\n const L = useLocalization();\n const labelId = useId();\n const hasChildren = (node.children?.length ?? 0) > 0;\n const isExpanded = expandedIds.has(node.id);\n const icon = typeof node.icon === \"function\" ? node.icon({ expanded: isExpanded }) : node.icon;\n const checkedState = indeterminate.has(node.id) ? \"indeterminate\" : checked.has(node.id);\n\n return (\n <div data-slot=\"tree-node\">\n <div\n className=\"flex items-center gap-1 py-1\"\n style={{ paddingLeft: depth * 16 }}\n data-testid={`tree-row-${node.id}`}\n >\n {hasChildren ? (\n <button\n type=\"button\"\n className=\"flex size-4 shrink-0 items-center justify-center text-muted-foreground\"\n aria-expanded={isExpanded}\n aria-label={isExpanded ? L(\"Tree:Collapse\") : L(\"Tree:Expand\")}\n data-testid={`tree-toggle-${node.id}`}\n onClick={() => onToggleExpanded(node.id)}\n >\n <ChevronRightIcon\n className={cn(\"size-4 transition-transform\", isExpanded && \"rotate-90\")}\n />\n </button>\n ) : (\n <span className=\"size-4 shrink-0\" aria-hidden=\"true\" />\n )}\n {icon !== undefined && icon !== null ? (\n <span className=\"flex shrink-0 items-center\" data-testid={`tree-icon-${node.id}`}>\n {icon}\n </span>\n ) : null}\n {checkable ? (\n <TriStateCheckbox\n checked={checkedState}\n disabled={node.disabled}\n aria-labelledby={labelId}\n data-testid={`tree-checkbox-${node.id}`}\n onCheckedChange={(value: boolean | \"indeterminate\") =>\n onCheckChange?.(node.id, value === true)\n }\n />\n ) : null}\n <span id={labelId} className=\"text-sm\">\n {node.label}\n </span>\n </div>\n {hasChildren && isExpanded ? (\n <div data-slot=\"tree-children\">\n {node.children?.map((child) => (\n <TreeRow\n key={child.id}\n node={child}\n depth={depth + 1}\n expandedIds={expandedIds}\n onToggleExpanded={onToggleExpanded}\n checkable={checkable}\n checked={checked}\n indeterminate={indeterminate}\n onCheckChange={onCheckChange}\n />\n ))}\n </div>\n ) : null}\n </div>\n );\n}\n",
18
+ "type": "registry:component",
19
+ "target": "components/tree/tree.tsx"
20
+ },
21
+ {
22
+ "path": "ui/blocks/tree/tri-state-checkbox.tsx",
23
+ "content": "import { CheckIcon, MinusIcon } from \"lucide-react\";\nimport { Checkbox as CheckboxPrimitive } from \"radix-ui\";\nimport type * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n/** 三态复选框:半选渲染横线而不是对勾。对勾表示「全选」,用它表示「部分」会让用户误读\n * (权限树上会把「部分授予」看成「全部授予」)。上游 shadcn 的 checkbox 不区分这两态,\n * 且 registry 不分发 ui/ 原语(用户装的是上游版本),故本块自带一份。\n * (data-table 的表头全选框刻意只藏对勾、不画横线,因为那里误读的代价小得多,不值得跨块依赖。)*/\nexport function TriStateCheckbox({\n className,\n ...props\n}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {\n return (\n <CheckboxPrimitive.Root\n data-slot=\"checkbox\"\n className={cn(\n \"group peer size-4 shrink-0 rounded-[4px] border border-input shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=indeterminate]:border-primary data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:data-[state=checked]:bg-primary dark:data-[state=indeterminate]:bg-primary\",\n className,\n )}\n {...props}\n >\n <CheckboxPrimitive.Indicator\n data-slot=\"checkbox-indicator\"\n className=\"grid place-content-center text-current transition-none\"\n >\n <CheckIcon\n data-slot=\"checkbox-check\"\n className=\"size-3.5 group-data-[state=indeterminate]:hidden\"\n />\n <MinusIcon\n data-slot=\"checkbox-dash\"\n className=\"hidden size-3.5 group-data-[state=indeterminate]:block\"\n />\n </CheckboxPrimitive.Indicator>\n </CheckboxPrimitive.Root>\n );\n}\n",
24
+ "type": "registry:component",
25
+ "target": "components/tree/tri-state-checkbox.tsx"
26
+ },
27
+ {
28
+ "path": "ui/blocks/tree/tree-helpers.ts",
29
+ "content": "import type { ReactNode } from \"react\";\n\n/**\n * 通用树节点,零业务知识。`label`/`icon` 由消费方传入,本文件不认识任何具体业务字段。\n * `icon` 为函数时按当前展开态求值,用于文件目录式开合图标(folder/folder-open)。\n */\nexport interface TreeNode {\n id: string;\n label: ReactNode;\n icon?: ReactNode | ((ctx: { expanded: boolean }) => ReactNode);\n children?: TreeNode[];\n disabled?: boolean;\n}\n\n/** 收集子树内全部节点 id(含自身),深度优先。 */\nexport function collectSubtreeIds(node: TreeNode): string[] {\n const ids = [node.id];\n for (const child of node.children ?? []) {\n ids.push(...collectSubtreeIds(child));\n }\n return ids;\n}\n\n/** 从根到目标节点的父节点 id 路径(根→父,不含自身);未找到返回 []。 */\nexport function findParentChain(nodes: TreeNode[], id: string): string[] {\n return searchParentChain(nodes, id, []) ?? [];\n}\n\nfunction searchParentChain(nodes: TreeNode[], id: string, ancestors: string[]): string[] | null {\n for (const node of nodes) {\n if (node.id === id) {\n return ancestors;\n }\n const found = searchParentChain(node.children ?? [], id, [...ancestors, node.id]);\n if (found !== null) {\n return found;\n }\n }\n return null;\n}\n\n/**\n * 推导「子树部分勾选」的父节点集合(自身未勾)。级联策略不在此处,这只是给消费方的只读推导:\n * 子树全勾/全不勾都不算半选;节点自身若已在 `checked` 里,即便子树非全勾也不重复标记。\n */\nexport function deriveIndeterminate(nodes: TreeNode[], checked: Set<string>): Set<string> {\n const result = new Set<string>();\n walkIndeterminate(nodes, checked, result);\n return result;\n}\n\ntype SubtreeState = \"checked\" | \"unchecked\" | \"mixed\";\n\nfunction walkIndeterminate(nodes: TreeNode[], checked: Set<string>, result: Set<string>): void {\n for (const node of nodes) {\n const children = node.children ?? [];\n if (children.length === 0) {\n continue;\n }\n if (subtreeState(node, checked) === \"mixed\" && !checked.has(node.id)) {\n result.add(node.id);\n }\n walkIndeterminate(children, checked, result);\n }\n}\n\nfunction subtreeState(node: TreeNode, checked: Set<string>): SubtreeState {\n const children = node.children ?? [];\n if (children.length === 0) {\n return checked.has(node.id) ? \"checked\" : \"unchecked\";\n }\n const childStates = children.map((child) => subtreeState(child, checked));\n if (childStates.every((state) => state === \"checked\")) {\n return \"checked\";\n }\n if (childStates.every((state) => state === \"unchecked\")) {\n return \"unchecked\";\n }\n return \"mixed\";\n}\n",
30
+ "type": "registry:component",
31
+ "target": "components/tree/tree-helpers.ts"
32
+ },
33
+ {
34
+ "path": "ui/blocks/tree/tree-messages.json",
35
+ "content": "{\n \"en\": {\n \"\": {\n \"Tree:Expand\": \"Expand\",\n \"Tree:Collapse\": \"Collapse\"\n }\n },\n \"zh-Hans\": {\n \"\": {\n \"Tree:Expand\": \"展开\",\n \"Tree:Collapse\": \"收起\"\n }\n }\n}\n",
36
+ "type": "registry:file",
37
+ "target": "components/tree/tree-messages.json"
38
+ }
39
+ ],
40
+ "type": "registry:block"
41
+ }