@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.
- package/LICENSE +21 -0
- package/README.md +29 -0
- package/auth/app/app-env.d.ts +24 -0
- package/auth/app/env.ts +17 -0
- package/auth/auth.config.ts +15 -0
- package/auth/index.ts +10 -0
- package/auth/manifest.json +9 -0
- package/auth/middleware.ts +21 -0
- package/auth/root/.env.example +23 -0
- package/auth/routes/api.auth.callback.ts +7 -0
- package/auth/routes/api.auth.login.ts +7 -0
- package/auth/routes/api.auth.logout.ts +7 -0
- package/auth/routes/api.culture.ts +6 -0
- package/auth/routes/api.tenant.ts +6 -0
- package/auth/runtime.ts +10 -0
- package/auth/server-fns.ts +119 -0
- package/package.json +43 -0
- package/public/r/abp-crud.json +51 -0
- package/public/r/abp-layout.json +93 -0
- package/public/r/abp-login.json +31 -0
- package/public/r/abp-permission-sheet.json +42 -0
- package/public/r/abp-sheet.json +25 -0
- package/public/r/abp-table.json +80 -0
- package/public/r/admin-pages.json +74 -0
- package/public/r/app-shell.json +120 -0
- package/public/r/combobox.json +39 -0
- package/public/r/data-table.json +92 -0
- package/public/r/date-picker.json +51 -0
- package/public/r/form.json +75 -0
- package/public/r/registry.json +723 -0
- package/public/r/tree.json +41 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "data-table",
|
|
4
|
+
"title": "Server-side Data Table",
|
|
5
|
+
"description": "Generic paginated/sortable/searchable data table with debounced search, driven by @tanstack/react-table state.",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"@tanstack/react-table@9.0.0-beta.59",
|
|
8
|
+
"@jcoder-stack/abp-react",
|
|
9
|
+
"lucide-react"
|
|
10
|
+
],
|
|
11
|
+
"registryDependencies": [
|
|
12
|
+
"table",
|
|
13
|
+
"select",
|
|
14
|
+
"button",
|
|
15
|
+
"input",
|
|
16
|
+
"skeleton",
|
|
17
|
+
"pagination",
|
|
18
|
+
"checkbox",
|
|
19
|
+
"dropdown-menu",
|
|
20
|
+
"label",
|
|
21
|
+
"separator"
|
|
22
|
+
],
|
|
23
|
+
"files": [
|
|
24
|
+
{
|
|
25
|
+
"path": "ui/blocks/data-table/dev-warn.ts",
|
|
26
|
+
"content": "const warned = new Set<string>();\n\n/**\n * DEV 期一次性告警。同一 `key` 全程只警一次。这些告警都在 render 或每渲染的 effect 里调用,\n * 不去重会刷屏。`key` 只用于去重、不出现在输出里;`message` 自带来源前缀。\n *\n * 生产构建下 `import.meta.env.DEV` 是字面 `false`,整个函数体被 DCE 掉。该守卫依赖 Vite 的\n * `import.meta.env`(TanStack Start 即 Vite);非 Vite 构建下需自行替换成等价的环境判断。\n */\nexport function devWarn(key: string, message: string): void {\n if (!import.meta.env.DEV) return;\n if (warned.has(key)) return;\n warned.add(key);\n console.warn(message);\n}\n",
|
|
27
|
+
"type": "registry:component",
|
|
28
|
+
"target": "components/data-table/dev-warn.ts"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"path": "ui/blocks/data-table/table-core.ts",
|
|
32
|
+
"content": "import {\n type ColumnDef,\n columnVisibilityFeature,\n createColumnHelper,\n type ReactTable,\n type RowData,\n rowPaginationFeature,\n rowSelectionFeature,\n rowSortingFeature,\n type Table,\n tableFeatures,\n} from \"@tanstack/react-table\";\nimport type { ReactNode } from \"react\";\n\n/** 基础特性映射;供 DataTable 与调用方追加特性时做加法合并。 */\nexport const baseFeatureMap = {\n rowSortingFeature,\n rowPaginationFeature,\n columnVisibilityFeature,\n rowSelectionFeature,\n};\n\n/** 全表共用的 v9 特性集;少注册一个对应方法即编译期消失。 */\nexport const features = tableFeatures(baseFeatureMap);\n\nexport type TableFeatures = typeof features;\n\n/** DataTable 内部构造的表实例类型(按基础特性定型)。`useDataTable()` 的调用方在自己的组件\n * 作用域里直接拿到它(`dt.table`);`DataTable` 的 `footer` 回调另外单独给一份。 */\nexport type TableInstance<TData extends RowData> = ReactTable<TableFeatures, TData>;\n\n/** 列 `cell` 上下文给出的表对象类型,比 `TableInstance` 窄。`ReactTable` 是\n * `Omit<Table, \"store\"> & { store, state, Subscribe, FlexRender }`,而 `state`/`Subscribe`/\n * `FlexRender` 只挂在 `useTable()` 另行 memo 出的返回对象上,cell 上下文永远拿不到。故行内插槽\n * (`AbpTableRowConfig` 的 `actions`/`menu`,喂给 `RowActionsMenu` 的 `rowActions`/`items` 属性)\n * 一律用这个类型,宁可少承诺也不给说谎的类型;要读当前状态走 `table.store`(本类型含),要完整\n * 实例走调用方自己持有的 `dt.table` 或 `DataTable` 的 `footer` 回调。 */\nexport type CellTableInstance<TData extends RowData> = Table<TableFeatures, TData>;\n\n/** 绑定好 TFeatures 的列定义别名,消费方只写这个,不感知泛型。 */\nexport type TableColumnDef<TData extends RowData> = ColumnDef<TableFeatures, TData, unknown>;\n\nexport const createTableColumnHelper = <TData extends RowData>() =>\n createColumnHelper<TableFeatures, TData>();\n\ndeclare module \"@tanstack/react-table\" {\n interface ColumnMeta<TFeatures, TData, TValue> {\n align?: \"left\" | \"right\" | \"center\";\n className?: string;\n label?: ReactNode;\n }\n}\n",
|
|
33
|
+
"type": "registry:component",
|
|
34
|
+
"target": "components/data-table/table-core.ts"
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"path": "ui/blocks/data-table/use-data-table-state.ts",
|
|
38
|
+
"content": "import type {\n OnChangeFn,\n PaginationState,\n RowSelectionState,\n SortingState,\n Updater,\n} from \"@tanstack/react-table\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\n\nconst SEARCH_DEBOUNCE_MS = 400;\n\nexport type TableDensity = \"comfortable\" | \"compact\";\n\n/**\n * 服务端分页表格的状态机:分页/排序/防抖搜索/页内行选择;filter 为已提交的搜索值,\n * searchInput 为输入框即时值。rowSelection 页内作用域,翻页/排序/提交搜索均清空。\n * 结构化查询参数不归这里管,那是 useAbpTable 的表单实例自己持有的东西。\n */\nexport function useDataTableState(opts: { defaultPageSize?: number } = {}) {\n const [pagination, setPagination] = useState<PaginationState>({\n pageIndex: 0,\n pageSize: opts.defaultPageSize ?? 10,\n });\n const [sorting, setSorting] = useState<SortingState>([]);\n const [searchInput, setSearchInput] = useState(\"\");\n const [filter, setFilter] = useState(\"\");\n const [rowSelection, setRowSelection] = useState<RowSelectionState>({});\n const [density, setDensity] = useState<TableDensity>(\"comfortable\");\n const timer = useRef<ReturnType<typeof setTimeout>>(undefined);\n\n const clearSelection = useCallback(() => setRowSelection({}), []);\n\n const resetPaging = useCallback(() => {\n // 新查询从第一页开始,否则会停留在越界页;页内选择同时作废\n setPagination((p) => ({ ...p, pageIndex: 0 }));\n setRowSelection({});\n }, []);\n\n const commit = useCallback(\n (value: string) => {\n setFilter(value);\n resetPaging();\n },\n [resetPaging],\n );\n\n const setSearch = useCallback(\n (value: string) => {\n setSearchInput(value);\n clearTimeout(timer.current);\n timer.current = setTimeout(() => commit(value), SEARCH_DEBOUNCE_MS);\n },\n [commit],\n );\n\n const flushSearch = useCallback(() => {\n clearTimeout(timer.current);\n commit(searchInput);\n }, [commit, searchInput]);\n\n useEffect(() => () => clearTimeout(timer.current), []);\n\n const onPaginationChange = useCallback((updater: Updater<PaginationState>) => {\n setPagination(updater);\n setRowSelection({});\n }, []);\n const onSortingChange = useCallback((updater: Updater<SortingState>) => {\n setSorting(updater);\n setRowSelection({});\n }, []);\n const onRowSelectionChange = useCallback<OnChangeFn<RowSelectionState>>((updater) => {\n setRowSelection(updater);\n }, []);\n\n /** 只保留指定行选中;批量操作部分失败回填用。ids 为 getRowId 产出的行 ID。 */\n const keepSelected = useCallback((ids: string[]) => {\n setRowSelection(Object.fromEntries(ids.map((id) => [id, true])));\n }, []);\n\n return {\n params: { pageIndex: pagination.pageIndex, pageSize: pagination.pageSize, sorting, filter },\n pagination,\n sorting,\n searchInput,\n setSearch,\n flushSearch,\n resetPaging,\n onPaginationChange,\n onSortingChange,\n rowSelection,\n onRowSelectionChange,\n selectedCount: Object.keys(rowSelection).length,\n clearSelection,\n keepSelected,\n density,\n setDensity,\n };\n}\n\nexport type DataTableState = ReturnType<typeof useDataTableState>;\n",
|
|
39
|
+
"type": "registry:component",
|
|
40
|
+
"target": "components/data-table/use-data-table-state.ts"
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"path": "ui/blocks/data-table/use-data-table.ts",
|
|
44
|
+
"content": "import { type Localize, useLocalization } from \"@jcoder-stack/abp-react/react\";\nimport {\n type RowData,\n type RowSelectionState,\n type TableOptions,\n tableFeatures,\n useTable,\n} from \"@tanstack/react-table\";\nimport { createElement, type MouseEvent, useEffect, useMemo, useRef } from \"react\";\nimport { devWarn } from \"@/components/data-table/dev-warn\";\nimport {\n baseFeatureMap,\n features,\n type TableColumnDef,\n type TableFeatures,\n type TableInstance,\n} from \"@/components/data-table/table-core\";\nimport {\n type DataTableState,\n useDataTableState,\n} from \"@/components/data-table/use-data-table-state\";\nimport { Checkbox } from \"@/components/ui/checkbox\";\n\n/** 共享的空选择态。写成 `?? {}` 每渲染都新建对象,TanStack 按身份比对会当成受控 state 变了,\n * 在「未受控 rowSelection + 其它受控 prop 变化」的组合下自持重渲染。 */\nconst EMPTY_ROW_SELECTION: RowSelectionState = {};\n\n/** 页内选择列:表头全选(含 indeterminate),行内单选;checked→事件由调用方 handler 消费 `event.target.checked`。 */\nfunction selectionColumn<TData extends RowData>(L: Localize): TableColumnDef<TData> {\n return {\n id: \"select\",\n enableSorting: false,\n // 组件注入的列不进 Columns 菜单:藏掉它只会让「已选 N 项」无从取消,且菜单里会出现无标签项\n enableHiding: false,\n meta: { className: \"w-10\" },\n header: ({ table }) =>\n createElement(Checkbox, {\n checked: table.getIsAllPageRowsSelected()\n ? true\n : table.getIsSomePageRowsSelected()\n ? \"indeterminate\"\n : false,\n onCheckedChange: (value: boolean | \"indeterminate\") =>\n table.getToggleAllPageRowsSelectedHandler()({ target: { checked: value === true } }),\n \"aria-label\": L(\"Table:SelectAll\"),\n // 半选只隐藏对勾,保留 indeterminate。对勾表示「全选」,拿它表示「部分」会误导;\n // aria-checked=\"mixed\" 得留着,否则读屏会把「已选 2 行」播报成「未选中」。\n // 选了几行由紧邻上方的批量条给出。\n // tree 块的 TriStateCheckbox 半选画横线,跟这里不一样,别当不一致来「修」:\n // 那边把「部分授予」误读成「全部授予」是授权问题,这里只影响感知,不值得为它加依赖。\n className: \"data-[state=indeterminate]:[&_[data-slot=checkbox-indicator]]:opacity-0\",\n }),\n cell: ({ row }) =>\n createElement(Checkbox, {\n checked: row.getIsSelected(),\n onCheckedChange: (value: boolean | \"indeterminate\") =>\n row.getToggleSelectedHandler()({ target: { checked: value === true } }),\n onClick: (e: MouseEvent) => e.stopPropagation(),\n \"aria-label\": L(\"Table:SelectRow\"),\n }),\n };\n}\n\nexport interface UseDataTableOptions<TData extends RowData> {\n /** 分页/排序/选择状态机;省略则内部自建(纯客户端数据场景,不需要与外部查询参数联动)。 */\n state?: DataTableState;\n /** 列定义,引用必须稳定。`useTable` 每渲染收到新数组会重建列模型,上层所有以 columns\n * 为依赖的 memo 也跟着失效。`header` 里要用 `L()` 就包一层 `useMemo(() => [...], [L])`,\n * `L` 的引用是稳的,等价于永久 memo。违反时 DEV 期会告警。 */\n columns: TableColumnDef<TData>[];\n data: TData[];\n pageCount?: number;\n rowCount?: number;\n selectable?: boolean;\n getRowId?: (row: TData, index: number) => string;\n /** 追加 TanStack 特性,与基础集加法合并。引用必须稳定,建议用模块级常量:`useTable` 只在\n * 挂载时构造一次表实例,特性在那时注册完,之后再变会被忽略,也不会触发重建。\n *\n * 注册不等于渲染。注册只让特性的状态和 API 可用,`DataTable` 的表现层不会自动跟上:\n * 注册 `columnResizingFeature` 后表头里没有 resize handle,注册 `rowExpandingFeature`\n * 后表体也只有 `row.getVisibleCells()`。要让它有画面就得改 `data-table.tsx`,\n * 这正是 copy-in 分发的用法。 */\n features?: Parameters<typeof tableFeatures>[0];\n /** 透传给 useTable,展开在受管选项之后,同名键调用方赢,接管即自负。\n * `features` 走专门的 prop,类型层禁止在这里传,免得打破加法合并的不变式。 */\n tableOptions?: Omit<Partial<TableOptions<TableFeatures, TData>>, \"features\">;\n}\n\nexport interface DataTableInstance<TData extends RowData> {\n table: TableInstance<TData>;\n state: DataTableState;\n selectedRows: TData[];\n /** 只保留这些行选中;部分失败时回填用。ids 是 `getRowId` 产出的行 ID。 */\n keepSelected: (ids: string[]) => void;\n pageCount: number;\n rowCount?: number;\n selectable: boolean;\n}\n\n/** 建 TanStack 表实例:合成勾选列、注册特性、绑定状态机、剪枝离场行的选中态。 */\nexport function useDataTable<TData extends RowData>(\n opts: UseDataTableOptions<TData>,\n): DataTableInstance<TData> {\n const L = useLocalization();\n // hooks 必须无条件调用:外部没给 state 也要建内部状态机,只是外部给了就不用\n const internalState = useDataTableState();\n const state = opts.state ?? internalState;\n\n const columns = useMemo(\n () => (opts.selectable ? [selectionColumn<TData>(L), ...opts.columns] : opts.columns),\n [opts.selectable, opts.columns, L],\n );\n const tableFeatureSet = useMemo(\n () => (opts.features ? tableFeatures({ ...baseFeatureMap, ...opts.features }) : features),\n [opts.features],\n );\n // 引用比较:features 的契约本就是「必须引用稳定」,引用变本身即误用,无需比较内容。\n const mountedFeatures = useRef(opts.features);\n useEffect(() => {\n if (mountedFeatures.current !== opts.features) {\n devWarn(\n \"data-table:features-changed\",\n \"data-table: features prop 在挂载后改变了引用,新值已被忽略。useTable 只在挂载时构造\" +\n \"表实例、特性注册在构造时完成,挂载后的变更不会触发重建。请把它提成模块级常量。\",\n );\n }\n }, [opts.features]);\n const prevColumns = useRef(opts.columns);\n const columnsChurn = useRef(0);\n useEffect(() => {\n if (!import.meta.env.DEV) return;\n columnsChurn.current = prevColumns.current === opts.columns ? 0 : columnsChurn.current + 1;\n prevColumns.current = opts.columns;\n if (columnsChurn.current >= 3) {\n devWarn(\n \"data-table:columns-churn\",\n \"data-table: columns 每次渲染都是新数组,表实例的列模型与上层所有以 columns 为依赖的 \" +\n \"memo 会全部失效。用 useMemo(() => [...], [L]) 包住——useLocalization 的 L 引用稳定,\" +\n \"等价于永久 memo。\",\n );\n }\n });\n const table = useTable({\n features: tableFeatureSet,\n data: opts.data,\n columns,\n pageCount: opts.pageCount ?? 1,\n onPaginationChange: state.onPaginationChange,\n onSortingChange: state.onSortingChange,\n onRowSelectionChange: state.onRowSelectionChange,\n enableRowSelection: opts.selectable ?? false,\n manualPagination: true,\n manualSorting: true,\n getRowId: opts.getRowId,\n ...opts.tableOptions,\n // 一层合并而非整体覆盖:tableOptions.state 只能按键覆盖/追加受管切片,\n // 不会把 pagination/sorting/rowSelection 整体挤掉造成组件渲染与表状态失步。\n state: {\n pagination: state.pagination,\n sorting: state.sorting,\n rowSelection: state.rowSelection ?? EMPTY_ROW_SELECTION,\n ...opts.tableOptions?.state,\n },\n });\n\n // 行离场(删除后 refetch、外部 invalidate)时剔除对应的选中 id。选中数有两个口径:\n // 批量条显隐看 state 键数,内容看在场行数,不剪枝就会分裂出「已选 0 项」的幽灵批量条。\n // 剪枝只看当前 data,跟 keepPreviousData 天然兼容,fetching 期间旧行还在场,不会误清。\n const { data, getRowId } = opts;\n const { rowSelection, onRowSelectionChange } = state;\n useEffect(() => {\n const selected = Object.keys(rowSelection);\n if (selected.length === 0) return;\n const present = new Set(data.map((row, index) => getRowId?.(row, index) ?? String(index)));\n if (selected.every((id) => present.has(id))) return;\n onRowSelectionChange(\n Object.fromEntries(Object.entries(rowSelection).filter(([id]) => present.has(id))),\n );\n }, [data, rowSelection, onRowSelectionChange, getRowId]);\n\n const selectedRows = table.getSelectedRowModel().rows.map((r) => r.original);\n\n return {\n table,\n state,\n selectedRows,\n keepSelected: state.keepSelected,\n pageCount: opts.pageCount ?? 1,\n rowCount: opts.rowCount,\n selectable: opts.selectable ?? false,\n };\n}\n",
|
|
45
|
+
"type": "registry:component",
|
|
46
|
+
"target": "components/data-table/use-data-table.ts"
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"path": "ui/blocks/data-table/data-table.tsx",
|
|
50
|
+
"content": "import { useLocalization } from \"@jcoder-stack/abp-react/react\";\nimport { flexRender, type PaginationState, type RowData } from \"@tanstack/react-table\";\nimport { ChevronDown, ChevronsUpDown, ChevronUp } from \"lucide-react\";\nimport { type ReactNode, useId } from \"react\";\nimport { DataTableFooter } from \"@/components/data-table/data-table-footer\";\nimport type { TableInstance } from \"@/components/data-table/table-core\";\nimport type { DataTableInstance } from \"@/components/data-table/use-data-table\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from \"@/components/ui/table\";\nimport { cn } from \"@/lib/utils\";\n\nconst DEFAULT_SKELETON_ROWS = 5;\n\nfunction alignClass(align?: \"left\" | \"right\" | \"center\") {\n // 右对齐即数字列(DESIGN.md):一并给等宽数字,否则 Inter 的比例数字宽窄不一,\n // 同列上下对不齐、跨组件(分页/页码)看着像换了字体。\n if (align === \"right\") return \"text-right tabular-nums\";\n if (align === \"center\") return \"text-center\";\n return \"\";\n}\n\nfunction justifyClass(align?: \"left\" | \"right\" | \"center\") {\n if (align === \"right\") return \"justify-end\";\n if (align === \"center\") return \"justify-center\";\n return \"justify-start\";\n}\n\nexport interface DataTableProps<TData extends RowData> {\n /** 由 `useDataTable` 构造的表实例;列/数据/分页/排序/选择全部经它接入。 */\n table: DataTableInstance<TData>;\n /** 无数据可显示、正在首次取数 → 渲染骨架行。 */\n loading?: boolean;\n /** 已有数据在位、正在取新数据 → 表体降透明度。与 `loading` 互斥,react-query 下传\n * `isFetching && !isPending`。表头不降透明度,排序仍可点,它也是用户定位刚点了什么的锚点。 */\n fetching?: boolean;\n /** 空状态。缺省是 `L(\"Table:Empty\")` 的居中单行。传了只替换 `<td>` 内的内容,外层\n * `<tr><td colSpan>` 仍由组件提供,不用自己算 colSpan。注意那个 `<td>` 带着\n * `h-24 text-center text-muted-foreground`,插图 + 标题 + 按钮这类空状态会被强制居中、\n * 标题被染成 muted 色,要退出得自己在内容上加覆盖类。 */\n empty?: ReactNode;\n /** 骨架行数;缺省 5。传 `\"pageSize\"` 跟随当前 `pagination.pageSize`,\n * 避免 `pageSize=50` 时 5 行骨架突变成 50 行数据的跳变。 */\n skeletonRows?: number | \"pageSize\";\n /** 行级属性,只开 `className`。事件走 `onRowClick`,万能透传会让调用方覆盖掉\n * `key`/`data-state` 这些组件自管的东西。收 `row.original` 而不是 `Row`,要判断的是业务\n * 状态而非表格行状态。不需要引用稳定,它不进任何 memo 依赖,只在渲染时调用。\n *\n * 要覆盖悬停态或选中态得自带对应变体,例如\n * `{ className: \"bg-red-50 hover:bg-red-50 data-[state=selected]:bg-red-50\" }`。\n * 只给 `bg-red-50` 的话,组件自带的 `hover:bg-muted/50` 和\n * `data-[state=selected]:bg-row-selected` 修饰符不同,tailwind-merge 不去重,\n * 悬停和选中时又特异性更高,会把你的颜色盖回去。 */\n rowProps?: (row: TData) => { className?: string };\n onRowClick?: (row: TData) => void;\n /** 每页条数选项,缺省 `[10, 20, 50]`。当前 `pagination.pageSize` 总会被并入去重升序,\n * 所以传了 `useDataTable({ defaultPageSize })` 忘了同步这里,触发器也不会空白。\n * 传了 `footer` 则本项不生效。 */\n pageSizes?: number[];\n /** 接管整个页脚区:总条数文字、每页行数下拉、分页器三者全部。缺省渲染内建页脚。\n * 做「加载更多」或无限滚动的调用方本就不要页码和每页行数,拆开接管只会让他们分别关\n * 三个开关;要保留哪部分自己从 ctx 拼,分页动作走 `ctx.table.nextPage()` 这些。\n * 传了它之后 `pageSizes` 不生效,内建的每页行数下拉不再渲染。 */\n footer?: (ctx: {\n table: TableInstance<TData>;\n pagination: PaginationState;\n pageCount: number;\n rowCount?: number;\n }) => ReactNode;\n /** 工具栏等散件,渲染在卡片内部、表格上方,顺序即摆放顺序。 */\n children?: ReactNode;\n}\n\n/** 服务端分页的通用表格。实例由 `useDataTable` 构造后传入,本组件只管表现层:\n * 骨架、空态、表头表体和内建页脚。行操作列由调用方当普通 column 传进来。 */\nexport function DataTable<TData extends RowData>(props: DataTableProps<TData>) {\n const L = useLocalization();\n const tableId = useId();\n const { table: dt } = props;\n const table = dt.table;\n const skeletonCount =\n props.skeletonRows === \"pageSize\"\n ? dt.state.pagination.pageSize\n : (props.skeletonRows ?? DEFAULT_SKELETON_ROWS);\n\n const footerContent = props.footer ? (\n props.footer({\n table,\n pagination: dt.state.pagination,\n pageCount: dt.pageCount,\n rowCount: dt.rowCount,\n })\n ) : (\n <DataTableFooter\n table={table}\n pagination={dt.state.pagination}\n pageCount={dt.pageCount}\n rowCount={dt.rowCount}\n pageSizes={props.pageSizes}\n />\n );\n\n return (\n <div className=\"overflow-hidden rounded-md border bg-card\">\n {props.children}\n <Table\n className={cn(dt.state.density === \"compact\" && \"[&_td]:py-1\")}\n aria-busy={props.loading || props.fetching ? true : undefined}\n >\n <TableHeader>\n {table.getHeaderGroups().map((headerGroup) => (\n <TableRow key={headerGroup.id}>\n {headerGroup.headers.map((header) => {\n const showSortIndex =\n table.state.sorting.length > 1 && Boolean(header.column.getIsSorted());\n const sortPriorityId = `${tableId}-sortpri-${header.id}`;\n return (\n <TableHead\n key={header.id}\n aria-sort={\n header.column.getIsSorted() === \"asc\"\n ? \"ascending\"\n : header.column.getIsSorted() === \"desc\"\n ? \"descending\"\n : header.column.getCanSort()\n ? \"none\"\n : undefined\n }\n className={cn(\n // label-caps:正字距的大写小字把表头标记为\"分类\"而非内容,\n // 是这套系统里唯一用正字距的地方(DESIGN.md Typography)。\n \"bg-muted/50 text-xs font-medium uppercase tracking-wide text-muted-foreground\",\n alignClass(header.column.columnDef.meta?.align),\n header.column.columnDef.meta?.className,\n )}\n >\n {header.isPlaceholder ? null : header.column.getCanSort() ? (\n <button\n type=\"button\"\n onClick={header.column.getToggleSortingHandler()}\n aria-describedby={showSortIndex ? sortPriorityId : undefined}\n className={cn(\n \"inline-flex w-full items-center gap-1.5 select-none hover:text-foreground\",\n \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n justifyClass(header.column.columnDef.meta?.align),\n )}\n >\n {flexRender(header.column.columnDef.header, header.getContext())}\n {header.column.getIsSorted() === \"asc\" ? (\n <ChevronUp className=\"size-3 text-primary\" />\n ) : header.column.getIsSorted() === \"desc\" ? (\n <ChevronDown className=\"size-3 text-primary\" />\n ) : (\n <ChevronsUpDown className=\"size-3 opacity-50\" />\n )}\n {showSortIndex && (\n // 优先级本就不可用 ARIA 表达(aria-sort 只有方向),裸数字留在可访问名里\n // 会污染读屏播报与语音控制的匹配目标(按钮会变成「Name1」而非「Name」)。\n <span\n aria-hidden=\"true\"\n className=\"text-2xs font-normal tabular-nums text-muted-foreground\"\n >\n {header.column.getSortIndex() + 1}\n </span>\n )}\n </button>\n ) : (\n flexRender(header.column.columnDef.header, header.getContext())\n )}\n {showSortIndex && (\n // 描述得放 button 外面,进 button 会并入可访问名、污染语音控制的匹配目标,\n // 而 aria-describedby 只影响描述不影响名称。但 th 自己也是 \"name from\n // content\" 的角色,不加 aria-hidden 这段文字照样会并进表头的可访问名。\n // aria-hidden 把它逐出常规内容遍历,aria-describedby 的直接引用不受影响,\n // 读屏仍读得到。\n <span id={sortPriorityId} aria-hidden=\"true\" className=\"sr-only\">\n {L(\"Table:SortPriority\", header.column.getSortIndex() + 1)}\n </span>\n )}\n </TableHead>\n );\n })}\n </TableRow>\n ))}\n </TableHeader>\n <TableBody\n className={cn(\n // delay-100 吃掉快请求的闪烁,100ms 内返回的几乎看不出变化,且不用 JS 定时器。\n // 没加 pointer-events-none:它只挡鼠标不挡键盘,两类用户会拿到不同能力;\n // keepPreviousData 期间在位的又正是上一次的有效数据,点它并不危险(行操作打的是\n // id,行真没了服务端会 404)。「正在刷新」靠降透明度 + aria-busy 表达,不禁用交互。\n props.fetching && \"opacity-60 transition-opacity duration-150 delay-100\",\n )}\n >\n {props.loading ? (\n Array.from({ length: skeletonCount }, (_, i) => (\n // biome-ignore lint/suspicious/noArrayIndexKey: 骨架占位行数固定且从不重排\n <TableRow key={`skeleton-${i}`}>\n <TableCell colSpan={table.getVisibleLeafColumns().length}>\n <Skeleton className=\"h-5 w-full\" />\n </TableCell>\n </TableRow>\n ))\n ) : table.getRowModel().rows.length === 0 ? (\n <TableRow>\n <TableCell\n colSpan={table.getVisibleLeafColumns().length}\n className=\"h-24 text-center text-muted-foreground\"\n >\n {props.empty ?? L(\"Table:Empty\")}\n </TableCell>\n </TableRow>\n ) : (\n table.getRowModel().rows.map((row) => (\n <TableRow\n key={row.id}\n data-state={row.getIsSelected() ? \"selected\" : undefined}\n className={cn(\n \"group hover:bg-muted/50 data-[state=selected]:bg-row-selected\",\n props.onRowClick &&\n \"cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring\",\n props.rowProps?.(row.original)?.className,\n )}\n tabIndex={props.onRowClick ? 0 : undefined}\n onClick={() => props.onRowClick?.(row.original)}\n onKeyDown={\n props.onRowClick &&\n ((e) => {\n if (e.key === \"Enter\" || e.key === \" \") {\n // 两个都要 preventDefault。Space 默认滚动页面;Enter 会在本次按键继续合成\n // 一次 click,落到激活后新获得焦点的元素上(详情抽屉里的「编辑」按钮),\n // 键盘激活就越过详情态直接进了编辑态。\n // 行操作菜单已在自己的 onKeyDown 里 stopPropagation,不会误触发行激活。\n e.preventDefault();\n props.onRowClick?.(row.original);\n }\n })\n }\n >\n {row.getVisibleCells().map((cell) => (\n <TableCell\n key={cell.id}\n className={cn(\n alignClass(cell.column.columnDef.meta?.align),\n cell.column.columnDef.meta?.className,\n )}\n >\n {flexRender(cell.column.columnDef.cell, cell.getContext())}\n </TableCell>\n ))}\n </TableRow>\n ))\n )}\n </TableBody>\n </Table>\n {/* footer 返回 falsy(无限滚动这类不要页脚的接管)时连外壳一起不渲染,\n 否则卡片底部会留一条 1px 上边框加一段空白。 */}\n {footerContent ? <div className=\"border-t px-3 py-2\">{footerContent}</div> : null}\n </div>\n );\n}\n",
|
|
51
|
+
"type": "registry:component",
|
|
52
|
+
"target": "components/data-table/data-table.tsx"
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"path": "ui/blocks/data-table/data-table-footer.tsx",
|
|
56
|
+
"content": "import { useLocalization } from \"@jcoder-stack/abp-react/react\";\nimport type { PaginationState, RowData } from \"@tanstack/react-table\";\nimport { ChevronLeft, ChevronRight } from \"lucide-react\";\nimport { type ReactNode, useMemo } from \"react\";\nimport type { TableInstance } from \"@/components/data-table/table-core\";\nimport {\n Pagination,\n PaginationContent,\n PaginationEllipsis,\n PaginationItem,\n PaginationLink,\n} from \"@/components/ui/pagination\";\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from \"@/components/ui/select\";\nimport { cn } from \"@/lib/utils\";\n\nconst DEFAULT_PAGE_SIZES = [10, 20, 50];\nconst PAGE_WINDOW_THRESHOLD = 7;\n\n/** 0-based 页码窗口:总页 ≤7 全显;否则首页/末页恒在,加当前页及左右各一页,其余折叠为省略号。 */\nexport function getPageItems(pageIndex: number, pageCount: number): (number | \"ellipsis\")[] {\n const totalPages = Math.max(pageCount, 1);\n if (totalPages <= PAGE_WINDOW_THRESHOLD) {\n return Array.from({ length: totalPages }, (_, i) => i);\n }\n\n const current = Math.min(Math.max(pageIndex, 0), totalPages - 1);\n const keep = new Set<number>([0, totalPages - 1, current]);\n if (current - 1 >= 0) keep.add(current - 1);\n if (current + 1 <= totalPages - 1) keep.add(current + 1);\n const sorted = [...keep].sort((a, b) => a - b);\n\n const items: (number | \"ellipsis\")[] = [];\n let previous: number | undefined;\n for (const page of sorted) {\n if (previous !== undefined && page - previous > 1) items.push(\"ellipsis\");\n items.push(page);\n previous = page;\n }\n return items;\n}\n\n/** 内建页脚:总条数 + 每页行数下拉 + 分页器。与表体正交,单独成文件降低 fork 进入成本。 */\nexport function DataTableFooter<TData extends RowData>(props: {\n table: TableInstance<TData>;\n pagination: PaginationState;\n pageCount: number;\n rowCount?: number;\n pageSizes?: number[];\n}): ReactNode {\n const L = useLocalization();\n const { table } = props;\n const canPrev = table.getCanPreviousPage();\n const canNext = table.getCanNextPage();\n const pageItems = getPageItems(props.pagination.pageIndex, props.pageCount);\n\n const sizeOptions = useMemo(() => {\n const merged = new Set([...(props.pageSizes ?? DEFAULT_PAGE_SIZES), props.pagination.pageSize]);\n return [...merged].sort((a, b) => a - b);\n }, [props.pageSizes, props.pagination.pageSize]);\n\n const prevItem = (\n <PaginationItem>\n <PaginationLink\n size=\"icon\"\n className={cn(\"size-8\", !canPrev && \"pointer-events-none opacity-50\")}\n aria-label={L(\"Table:Previous\")}\n aria-disabled={!canPrev}\n href={canPrev ? \"#\" : undefined}\n onClick={(e) => {\n e.preventDefault();\n if (canPrev) table.previousPage();\n }}\n >\n <ChevronLeft />\n </PaginationLink>\n </PaginationItem>\n );\n\n const nextItem = (\n <PaginationItem>\n <PaginationLink\n size=\"icon\"\n className={cn(\"size-8\", !canNext && \"pointer-events-none opacity-50\")}\n aria-label={L(\"Table:Next\")}\n aria-disabled={!canNext}\n href={canNext ? \"#\" : undefined}\n onClick={(e) => {\n e.preventDefault();\n if (canNext) table.nextPage();\n }}\n >\n <ChevronRight />\n </PaginationLink>\n </PaginationItem>\n );\n\n return (\n <div className=\"flex items-center justify-between gap-2\">\n <div className=\"flex items-center gap-2 text-sm tabular-nums text-muted-foreground\">\n {props.rowCount !== undefined && props.rowCount > 0 && (\n <span>{L(\"Table:Total\", props.rowCount)}</span>\n )}\n <span>{L(\"Table:RowsPerPage\")}</span>\n <Select\n value={String(props.pagination.pageSize)}\n onValueChange={(v: string) => table.setPagination({ pageIndex: 0, pageSize: Number(v) })}\n >\n <SelectTrigger size=\"sm\" className=\"tabular-nums\">\n <SelectValue />\n </SelectTrigger>\n <SelectContent className=\"tabular-nums\">\n {sizeOptions.map((size) => (\n <SelectItem key={size} value={String(size)}>\n {size}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n <div className=\"flex items-center gap-2\">\n <span className=\"text-sm tabular-nums text-muted-foreground md:hidden\">\n {L(\"Table:PageOf\", props.pagination.pageIndex + 1, Math.max(props.pageCount, 1))}\n </span>\n <Pagination className=\"mx-0 w-auto md:hidden\">\n <PaginationContent>\n {prevItem}\n {nextItem}\n </PaginationContent>\n </Pagination>\n <Pagination className=\"mx-0 hidden w-auto md:flex\">\n <PaginationContent>\n {prevItem}\n {pageItems.map((item, i) =>\n item === \"ellipsis\" ? (\n // biome-ignore lint/suspicious/noArrayIndexKey: 省略号在窗口内位置固定,不参与重排\n <PaginationItem key={`ellipsis-${i}`}>\n <PaginationEllipsis />\n </PaginationItem>\n ) : (\n <PaginationItem key={item}>\n <PaginationLink\n size=\"icon\"\n className={cn(\n \"size-8 tabular-nums\",\n item === props.pagination.pageIndex &&\n \"border-primary bg-primary text-primary-foreground hover:bg-primary-hover hover:text-primary-foreground dark:border-primary dark:bg-primary dark:hover:bg-primary-hover\",\n )}\n isActive={item === props.pagination.pageIndex}\n href=\"#\"\n onClick={(e) => {\n e.preventDefault();\n table.setPageIndex(item);\n }}\n >\n {item + 1}\n </PaginationLink>\n </PaginationItem>\n ),\n )}\n {nextItem}\n </PaginationContent>\n </Pagination>\n </div>\n </div>\n );\n}\n",
|
|
57
|
+
"type": "registry:component",
|
|
58
|
+
"target": "components/data-table/data-table-footer.tsx"
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"path": "ui/blocks/data-table/data-table-toolbar.tsx",
|
|
62
|
+
"content": "import { useLocalization } from \"@jcoder-stack/abp-react/react\";\nimport type { RowData } from \"@tanstack/react-table\";\nimport { Download, RefreshCw, Rows3 } from \"lucide-react\";\nimport type { ReactNode } from \"react\";\nimport type { DataTableInstance } from \"@/components/data-table/use-data-table\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuRadioGroup,\n DropdownMenuRadioItem,\n DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { Input } from \"@/components/ui/input\";\nimport { Separator } from \"@/components/ui/separator\";\nimport { cn } from \"@/lib/utils\";\n\n/** 顶部条功能图标钮的共同外观:ghost 32px 方钮、静态色比正文更轻,hover 才提亮。 */\nconst UTILITY_ICON_CLASS = \"size-8 text-muted-foreground\";\n\nexport interface DataTableToolbarProps<TData extends RowData> {\n table: DataTableInstance<TData>;\n /** 是否渲染内建搜索框;缺省 true。传了 `left` 时搜索框让位。 */\n search?: boolean;\n searchPlaceholder?: string;\n /** 左区自定义内容(如内联查询字段);提供时替代内建搜索框。 */\n left?: ReactNode;\n /** 有选中行时替换整个左区的批量操作内容;缺席则选中时左区维持原样。 */\n bulk?: ReactNode;\n /** 刷新回调;缺席不渲染刷新按钮。`refreshing` 为 true 时图标旋转。 */\n onRefresh?: () => void;\n refreshing?: boolean;\n /** 导出插槽;缺席不渲染导出按钮,组件库不内置导出实现。 */\n onExport?: () => void;\n /** 页面级动作(如「新增」),primary 归它;与右侧功能图标组之间以竖线分隔。 */\n actions?: ReactNode;\n /** 功能区最前的散件(如查询面板开合钮),排在刷新之前,与「改表格外观」的那几个分开。 */\n utilityLeading?: ReactNode;\n /** trailing 区:列显隐菜单等散件,并入右侧功能图标组。 */\n children?: ReactNode;\n}\n\n/** 表格卡片顶部条:左区三态(自定义内容/搜索框 ⇄ 批量操作),右区恒定\n * (actions │ utilityLeading · 刷新 · 导出 · 密度 · trailing)。Enter 立即提交搜索。 */\nexport function DataTableToolbar<TData extends RowData>(props: DataTableToolbarProps<TData>) {\n const L = useLocalization();\n const { state } = props.table;\n const showSearch = props.search ?? true;\n const bulkActive = props.bulk !== undefined && props.table.selectedRows.length > 0;\n\n const left = bulkActive\n ? props.bulk\n : (props.left ??\n (showSearch ? (\n <Input\n className=\"h-8 w-56\"\n value={state.searchInput}\n placeholder={props.searchPlaceholder ?? L(\"Table:Search\")}\n onChange={(e) => state.setSearch(e.target.value)}\n onKeyDown={(e) => e.key === \"Enter\" && state.flushSearch()}\n />\n ) : null));\n\n return (\n <div className=\"flex flex-wrap items-center gap-2 border-b px-3 py-2\">\n {left}\n <div className=\"ml-auto flex items-center gap-2\">\n {props.actions}\n {props.actions ? <Separator orientation=\"vertical\" className=\"mx-1 !h-5\" /> : null}\n <div className=\"flex items-center gap-1\">\n {props.utilityLeading}\n {props.onRefresh && (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon\"\n className={UTILITY_ICON_CLASS}\n aria-label={L(\"Table:Refresh\")}\n onClick={props.onRefresh}\n >\n <RefreshCw className={cn(props.refreshing && \"animate-spin\")} />\n </Button>\n )}\n {props.onExport && (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon\"\n className={UTILITY_ICON_CLASS}\n aria-label={L(\"Table:Export\")}\n onClick={props.onExport}\n >\n <Download />\n </Button>\n )}\n <DropdownMenu>\n <DropdownMenuTrigger asChild>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon\"\n className={UTILITY_ICON_CLASS}\n aria-label={L(\"Table:Density\")}\n >\n <Rows3 />\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent align=\"end\">\n <DropdownMenuRadioGroup\n value={state.density}\n onValueChange={(value) => {\n // 收窄而非断言:菜单项之外的值(将来加档位忘了同步这里)宁可无声忽略,\n // 也不要把非法字符串塞进状态机。\n if (value === \"comfortable\" || value === \"compact\") state.setDensity(value);\n }}\n >\n <DropdownMenuRadioItem value=\"comfortable\">\n {L(\"Table:DensityComfortable\")}\n </DropdownMenuRadioItem>\n <DropdownMenuRadioItem value=\"compact\">\n {L(\"Table:DensityCompact\")}\n </DropdownMenuRadioItem>\n </DropdownMenuRadioGroup>\n </DropdownMenuContent>\n </DropdownMenu>\n {props.children}\n </div>\n </div>\n </div>\n );\n}\n",
|
|
63
|
+
"type": "registry:component",
|
|
64
|
+
"target": "components/data-table/data-table-toolbar.tsx"
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
"path": "ui/blocks/data-table/column-label.ts",
|
|
68
|
+
"content": "import type { HeaderContext, RowData } from \"@tanstack/react-table\";\nimport { flexRender } from \"@tanstack/react-table\";\nimport type { ReactNode } from \"react\";\nimport type { CellTableInstance, TableFeatures } from \"@/components/data-table/table-core\";\n\n/** 列标签解析阶梯:meta.label → 表头 flexRender 结果(与表头逐字一致)→ 隐藏列兜底直调\n * header 渲染器 → column.id。\n *\n * 契约:列隐藏后 `getHeaderGroups()`(进而 `getFlatHeaders()`)不会给出该列的表头对象,v9\n * 引擎在 `getVisibleLeafColumns()` 就把它筛掉了。兜底分支不含真实 `header`,只拼一个\n * `{ table, column }` 上下文调用 `columnDef.header`;若你的 header 渲染器读取上下文里的\n * `header` 字段,请改设 `meta.label` 显式指定菜单文案。 */\nexport function resolveColumnLabel<TData extends RowData>(\n table: CellTableInstance<TData>,\n columnId: string,\n): ReactNode {\n const column = table.getAllLeafColumns().find((c) => c.id === columnId);\n const metaLabel = column?.columnDef.meta?.label;\n if (metaLabel !== undefined) return metaLabel;\n const header = table.getFlatHeaders().find((h) => h.column.id === columnId);\n if (header && !header.isPlaceholder) {\n return flexRender(header.column.columnDef.header, header.getContext());\n }\n if (!column) return columnId;\n const rawHeader = column.columnDef.header;\n if (typeof rawHeader === \"string\") return rawHeader;\n if (typeof rawHeader === \"function\") {\n // 隐藏列没有 Header 实例可用;用真实 table/column 拼一个残缺上下文喂给渲染器,仅本文件内断言。\n const context = { table, column } as HeaderContext<TableFeatures, TData, unknown>;\n return rawHeader(context);\n }\n return columnId;\n}\n",
|
|
69
|
+
"type": "registry:component",
|
|
70
|
+
"target": "components/data-table/column-label.ts"
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"path": "ui/blocks/data-table/data-table-columns-menu.tsx",
|
|
74
|
+
"content": "import { useLocalization } from \"@jcoder-stack/abp-react/react\";\nimport type { Column, RowData } from \"@tanstack/react-table\";\nimport { TableProperties } from \"lucide-react\";\nimport type { ReactNode } from \"react\";\nimport { resolveColumnLabel } from \"@/components/data-table/column-label\";\nimport type { TableFeatures, TableInstance } from \"@/components/data-table/table-core\";\nimport type { DataTableInstance } from \"@/components/data-table/use-data-table\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n DropdownMenu,\n DropdownMenuCheckboxItem,\n DropdownMenuContent,\n DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface DataTableColumnsMenuProps<TData extends RowData> {\n table: DataTableInstance<TData>;\n /** 菜单末尾追加自定义项;`items` 输入是完整表实例,非单列。 */\n items?: (table: TableInstance<TData>) => ReactNode;\n /** 覆盖单列标签;缺省走 resolveColumnLabel 的三级阶梯。 */\n label?: (column: Column<TableFeatures, TData, unknown>) => ReactNode;\n className?: string;\n}\n\n/** 列显隐菜单;出现哪些列由列定义的 `enableHiding` 原生控制,本组件不做二次过滤。 */\nexport function DataTableColumnsMenu<TData extends RowData>(\n props: DataTableColumnsMenuProps<TData>,\n) {\n const L = useLocalization();\n const table = props.table.table;\n const columns = table.getAllLeafColumns().filter((c) => c.getCanHide());\n\n return (\n <DropdownMenu modal={false}>\n <DropdownMenuTrigger asChild>\n <Button\n variant=\"ghost\"\n size=\"icon\"\n className={cn(\"size-8 text-muted-foreground\", props.className)}\n aria-label={L(\"Table:Columns\")}\n title={L(\"Table:Columns\")}\n >\n <TableProperties />\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent align=\"end\">\n {columns.map((column) => (\n <DropdownMenuCheckboxItem\n key={column.id}\n checked={column.getIsVisible()}\n onCheckedChange={(value) => column.toggleVisibility(value === true)}\n >\n {props.label?.(column) ?? resolveColumnLabel(table, column.id)}\n </DropdownMenuCheckboxItem>\n ))}\n {props.items?.(table)}\n </DropdownMenuContent>\n </DropdownMenu>\n );\n}\n",
|
|
75
|
+
"type": "registry:component",
|
|
76
|
+
"target": "components/data-table/data-table-columns-menu.tsx"
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
"path": "ui/blocks/data-table/data-table-sort-menu.tsx",
|
|
80
|
+
"content": "import { useLocalization } from \"@jcoder-stack/abp-react/react\";\nimport type { Column, RowData } from \"@tanstack/react-table\";\nimport { ArrowUpDown } from \"lucide-react\";\nimport type { ReactNode } from \"react\";\nimport { resolveColumnLabel } from \"@/components/data-table/column-label\";\nimport type { TableFeatures, TableInstance } from \"@/components/data-table/table-core\";\nimport type { DataTableInstance } from \"@/components/data-table/use-data-table\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuSeparator,\n DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface DataTableSortMenuProps<TData extends RowData> {\n table: DataTableInstance<TData>;\n /** 菜单末尾追加自定义项;`items` 输入是完整表实例,非单列。 */\n items?: (table: TableInstance<TData>) => ReactNode;\n /** 覆盖单列标签;缺省走 resolveColumnLabel 的三级阶梯。 */\n label?: (column: Column<TableFeatures, TData, unknown>) => ReactNode;\n className?: string;\n}\n\n/** 排序菜单:每次点选**叠加**到现有排序,不替换。已在排序中的列点了是翻方向,不在的追加到末尾。\n * 退出多列态走「清除排序」项。与表头点击的心智模型不同:表头默认替换、按住 shift 才叠加,\n * 菜单里没有 shift 手势可用,所以默认就是叠加。出现哪些列仍由列定义的 `enableSorting` 原生控制。 */\nexport function DataTableSortMenu<TData extends RowData>(props: DataTableSortMenuProps<TData>) {\n const L = useLocalization();\n const table = props.table.table;\n const columns = table.getAllLeafColumns().filter((c) => c.getCanSort());\n\n return (\n <DropdownMenu modal={false}>\n <DropdownMenuTrigger asChild>\n <Button\n variant=\"ghost\"\n size=\"icon\"\n className={cn(\"size-8 text-muted-foreground\", props.className)}\n aria-label={L(\"Table:Sort\")}\n title={L(\"Table:Sort\")}\n >\n <ArrowUpDown />\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent align=\"end\">\n {columns.map((column) => (\n <DropdownMenuItem\n key={column.id}\n // 第二参 multi=true:菜单里点第二列不再把第一列压掉。没有它,菜单会\n // 破坏用户刚用 shift+click 建立的多列排序状态。\n onClick={() => column.toggleSorting(column.getIsSorted() === \"asc\", true)}\n >\n {props.label?.(column) ?? resolveColumnLabel(table, column.id)}\n </DropdownMenuItem>\n ))}\n <DropdownMenuSeparator />\n <DropdownMenuItem onClick={() => table.setSorting([])}>\n {L(\"Table:SortClear\")}\n </DropdownMenuItem>\n {props.items?.(table)}\n </DropdownMenuContent>\n </DropdownMenu>\n );\n}\n\nexport interface DataTableClearSortButtonProps<TData extends RowData> {\n table: DataTableInstance<TData>;\n className?: string;\n}\n\n/** 多列排序的退出口:仅在两列及以上排序时出现,点击一次清空全部排序。\n * 可见性判据留在组件内部,调用方无条件渲染即可。「何时需要退出口」是表现层知识,\n * 不该外泄给组装层。单列排序不给出口:表头三态循环(asc→desc→无)两次点击即可退出,\n * 为它多摆一颗常驻按钮是噪声。 */\nexport function DataTableClearSortButton<TData extends RowData>(\n props: DataTableClearSortButtonProps<TData>,\n) {\n const L = useLocalization();\n const table = props.table.table;\n if (table.state.sorting.length <= 1) return null;\n\n return (\n <Button\n variant=\"ghost\"\n size=\"sm\"\n className={cn(\"text-muted-foreground\", props.className)}\n onClick={() => table.setSorting([])}\n >\n {L(\"Table:SortClear\")}\n </Button>\n );\n}\n",
|
|
81
|
+
"type": "registry:component",
|
|
82
|
+
"target": "components/data-table/data-table-sort-menu.tsx"
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
"path": "ui/blocks/data-table/table-messages.json",
|
|
86
|
+
"content": "{\n \"en\": {\n \"\": {\n \"Table:Search\": \"Search…\",\n \"Table:Empty\": \"No data\",\n \"Table:Query\": \"Query\",\n \"Table:Reset\": \"Reset\",\n \"Table:RowsPerPage\": \"Rows per page\",\n \"Table:PageOf\": \"Page {0} of {1}\",\n \"Table:Total\": \"{0} items\",\n \"Table:Previous\": \"Previous\",\n \"Table:Next\": \"Next\",\n \"Table:Actions\": \"Actions\",\n \"Table:View\": \"View\",\n \"Table:SelectAll\": \"Select all rows\",\n \"Table:SelectRow\": \"Select row\",\n \"Table:NSelected\": \"{0} selected\",\n \"Table:Clear\": \"Clear\",\n \"Table:Columns\": \"Columns\",\n \"Table:Sort\": \"Sort\",\n \"Table:SortClear\": \"Clear sort\",\n \"Table:SortPriority\": \"Sort priority {0}\",\n \"Table:Expand\": \"More\",\n \"Table:Collapse\": \"Less\",\n \"Table:Refresh\": \"Refresh\",\n \"Table:Export\": \"Export\",\n \"Table:Density\": \"Density\",\n \"Table:DensityComfortable\": \"Comfortable\",\n \"Table:DensityCompact\": \"Compact\",\n \"Table:Filter\": \"Filters\",\n \"Table:FilterActive\": \"{0} filters applied\"\n }\n },\n \"zh-Hans\": {\n \"\": {\n \"Table:Search\": \"搜索…\",\n \"Table:Empty\": \"暂无数据\",\n \"Table:Query\": \"查询\",\n \"Table:Reset\": \"重置\",\n \"Table:RowsPerPage\": \"每页行数\",\n \"Table:PageOf\": \"第 {0} / {1} 页\",\n \"Table:Total\": \"共 {0} 条\",\n \"Table:Previous\": \"上一页\",\n \"Table:Next\": \"下一页\",\n \"Table:Actions\": \"操作\",\n \"Table:View\": \"查看\",\n \"Table:SelectAll\": \"全选\",\n \"Table:SelectRow\": \"选择该行\",\n \"Table:NSelected\": \"已选 {0} 项\",\n \"Table:Clear\": \"清除\",\n \"Table:Columns\": \"列\",\n \"Table:Sort\": \"排序\",\n \"Table:SortClear\": \"清除排序\",\n \"Table:SortPriority\": \"排序优先级 {0}\",\n \"Table:Expand\": \"展开\",\n \"Table:Collapse\": \"收起\",\n \"Table:Refresh\": \"刷新\",\n \"Table:Export\": \"导出\",\n \"Table:Density\": \"密度\",\n \"Table:DensityComfortable\": \"舒适\",\n \"Table:DensityCompact\": \"紧凑\",\n \"Table:Filter\": \"筛选\",\n \"Table:FilterActive\": \"已应用 {0} 个筛选条件\"\n }\n }\n}\n",
|
|
87
|
+
"type": "registry:file",
|
|
88
|
+
"target": "components/data-table/table-messages.json"
|
|
89
|
+
}
|
|
90
|
+
],
|
|
91
|
+
"type": "registry:block"
|
|
92
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "date-picker",
|
|
4
|
+
"title": "Date Picker",
|
|
5
|
+
"description": "Date, date-range and date-time pickers built on Calendar + Popover, with ISO date-string helpers.",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"date-fns",
|
|
8
|
+
"react-day-picker",
|
|
9
|
+
"@jcoder-stack/abp-react",
|
|
10
|
+
"lucide-react"
|
|
11
|
+
],
|
|
12
|
+
"registryDependencies": [
|
|
13
|
+
"button",
|
|
14
|
+
"calendar",
|
|
15
|
+
"popover",
|
|
16
|
+
"input"
|
|
17
|
+
],
|
|
18
|
+
"files": [
|
|
19
|
+
{
|
|
20
|
+
"path": "ui/blocks/date-picker/date-io.ts",
|
|
21
|
+
"content": "import { format, isValid, parse } from \"date-fns\";\n\nexport const ISO_DATE = \"yyyy-MM-dd\";\nexport const ISO_DATE_TIME = \"yyyy-MM-dd'T'HH:mm\";\n\n/** ISO 字符串 → Date;空串/非法输入返回 undefined。不用 `new Date(string)`,纯日期\n * 字符串按 UTC 解析,正时区显示会串前一天。 */\nexport function parseIso(value: string | undefined, pattern: string): Date | undefined {\n if (!value) return undefined;\n const parsed = parse(value, pattern, new Date());\n return isValid(parsed) ? parsed : undefined;\n}\n\n/** Date → ISO 字符串;undefined → \"\"(表单空值语义,与 DTO 可选字段对齐)。 */\nexport function formatIso(date: Date | undefined, pattern: string): string {\n return date ? format(date, pattern) : \"\";\n}\n",
|
|
22
|
+
"type": "registry:component",
|
|
23
|
+
"target": "components/date-picker/date-io.ts"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"path": "ui/blocks/date-picker/date-picker.tsx",
|
|
27
|
+
"content": "import { useCulture, useLocalization } from \"@jcoder-stack/abp-react/react\";\nimport type { Locale } from \"date-fns\";\nimport { format } from \"date-fns\";\nimport { zhCN } from \"date-fns/locale\";\nimport { CalendarIcon } from \"lucide-react\";\nimport { useState } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Calendar } from \"@/components/ui/calendar\";\nimport { Popover, PopoverContent, PopoverTrigger } from \"@/components/ui/popover\";\nimport { cn } from \"@/lib/utils\";\n\n/** culture → date-fns locale;未映射的 culture 返回 undefined(= react-day-picker 默认 enUS)。\n * 只影响展示格式,存储值恒为 ISO 字符串(见 date-io.ts)。 */\nexport function dateFnsLocale(culture: string): Locale | undefined {\n return culture === \"zh-Hans\" ? zhCN : undefined;\n}\n\nexport interface DatePickerProps {\n value?: Date;\n onChange: (date: Date | undefined) => void;\n id?: string;\n disabled?: boolean;\n placeholder?: string;\n \"aria-invalid\"?: boolean;\n \"aria-required\"?: boolean;\n}\n\nexport function DatePicker(props: DatePickerProps) {\n const L = useLocalization();\n const culture = useCulture();\n const [open, setOpen] = useState(false);\n const locale = dateFnsLocale(culture);\n const label = props.value\n ? format(props.value, \"PPP\", { locale })\n : (props.placeholder ?? L(\"DatePicker:Placeholder\"));\n return (\n <Popover open={open} onOpenChange={setOpen}>\n <PopoverTrigger asChild>\n <Button\n id={props.id}\n type=\"button\"\n variant=\"outline\"\n disabled={props.disabled}\n aria-invalid={props[\"aria-invalid\"] || undefined}\n aria-required={props[\"aria-required\"] || undefined}\n // 窄容器里格式化后的日期常超出按钮宽度;Button 的 whitespace-nowrap 会直接裁断,\n // 故文本自己 truncate,完整值走 title。占位文案没有「完整值」可揭示,不给 title。\n title={props.value ? label : undefined}\n className={cn(\n \"w-full justify-start text-left font-normal\",\n !props.value && \"text-muted-foreground\",\n )}\n >\n <CalendarIcon className=\"size-4\" />\n <span className=\"min-w-0 truncate\">{label}</span>\n </Button>\n </PopoverTrigger>\n <PopoverContent className=\"w-auto p-0\" align=\"start\">\n <Calendar\n mode=\"single\"\n selected={props.value}\n // 不给锚点时日历停在当前月,编辑一条旧记录要手动往回翻几十个月才见到原值。\n // defaultMonth 只在挂载时读一次,而 Popover 关闭即卸载内容,于是每次打开都按\n // 当时的 value 重新起锚,用户上一次翻到的月份不会残留。\n defaultMonth={props.value}\n onSelect={(date) => {\n props.onChange(date);\n setOpen(false);\n }}\n locale={locale}\n autoFocus\n />\n </PopoverContent>\n </Popover>\n );\n}\n",
|
|
28
|
+
"type": "registry:component",
|
|
29
|
+
"target": "components/date-picker/date-picker.tsx"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"path": "ui/blocks/date-picker/date-range-picker.tsx",
|
|
33
|
+
"content": "import { useCulture, useLocalization } from \"@jcoder-stack/abp-react/react\";\nimport { format } from \"date-fns\";\nimport { CalendarIcon } from \"lucide-react\";\nimport { useState } from \"react\";\nimport { dateFnsLocale } from \"@/components/date-picker/date-picker\";\nimport { Button } from \"@/components/ui/button\";\nimport { Calendar } from \"@/components/ui/calendar\";\nimport { Popover, PopoverContent, PopoverTrigger } from \"@/components/ui/popover\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface DateRange {\n from?: Date;\n to?: Date;\n}\n\n/** 只有起止都齐全才算日历可继续拼接的锚点;只给了 from 的半截值会被\n * react-day-picker 的 addToRange 当成「已有起点」,下一次点选立刻收尾成\n * from~该天,无法再分两步选出真正的止点,故半截值一律当空处理。 */\nfunction toCalendarRange(range: DateRange | undefined): DateRange | undefined {\n return range?.from !== undefined && range.to !== undefined ? range : undefined;\n}\n\nexport interface DateRangePickerProps {\n value?: DateRange;\n onChange: (range: DateRange | undefined) => void;\n id?: string;\n disabled?: boolean;\n placeholder?: string;\n \"aria-invalid\"?: boolean;\n \"aria-required\"?: boolean;\n}\n\nexport function DateRangePicker(props: DateRangePickerProps) {\n const L = useLocalization();\n const culture = useCulture();\n const [open, setOpen] = useState(false);\n const [working, setWorking] = useState(() => toCalendarRange(props.value));\n const locale = dateFnsLocale(culture);\n const { value } = props;\n const label =\n value?.from !== undefined\n ? `${format(value.from, \"PP\", { locale })} – ${\n value.to !== undefined ? format(value.to, \"PP\", { locale }) : \"\"\n }`\n : (props.placeholder ?? L(\"DatePicker:RangePlaceholder\"));\n return (\n <Popover\n open={open}\n onOpenChange={(next) => {\n setOpen(next);\n // 打开瞬间用最新受控 value 重新起锚;打开期间的中间选区只活在 working\n // 里,不受父组件重渲染影响,两次点选之间不会被打断。\n if (next) setWorking(toCalendarRange(value));\n }}\n >\n <PopoverTrigger asChild>\n <Button\n id={props.id}\n type=\"button\"\n variant=\"outline\"\n disabled={props.disabled}\n aria-invalid={props[\"aria-invalid\"] || undefined}\n aria-required={props[\"aria-required\"] || undefined}\n // 区间文本是两个完整日期,窄容器里必然超宽;Button 的 whitespace-nowrap 会直接裁断,\n // 故文本自己 truncate,完整区间走 title。占位文案没有「完整值」可揭示,不给 title。\n title={value?.from !== undefined ? label : undefined}\n className={cn(\n \"w-full justify-start text-left font-normal\",\n value?.from === undefined && \"text-muted-foreground\",\n )}\n >\n <CalendarIcon className=\"size-4\" />\n <span className=\"min-w-0 truncate\">{label}</span>\n </Button>\n </PopoverTrigger>\n <PopoverContent className=\"w-auto p-0\" align=\"start\">\n <Calendar\n mode=\"range\"\n numberOfMonths={2}\n // 相邻两个月的网格互相包含对方的溢出日,开启溢出日时同一天会在两处都带上选中高亮,\n // 看起来是重复的一天、区间断成两截。\n showOutsideDays={false}\n resetOnSelect\n // 见 date-picker.tsx:按起始日起锚(numberOfMonths=2 时它是左侧那个月)。锚点取\n // 用 props.value 而不是 working,半截区间的 working 被归零,仍应停在用户已选的起始月。\n defaultMonth={value?.from}\n selected={working ? { from: working.from, to: working.to } : undefined}\n onSelect={(range) => {\n setWorking(range);\n props.onChange(range);\n if (range?.from !== undefined && range?.to !== undefined) setOpen(false);\n }}\n locale={locale}\n autoFocus\n />\n </PopoverContent>\n </Popover>\n );\n}\n",
|
|
34
|
+
"type": "registry:component",
|
|
35
|
+
"target": "components/date-picker/date-range-picker.tsx"
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"path": "ui/blocks/date-picker/date-time-picker.tsx",
|
|
39
|
+
"content": "import { useCulture, useLocalization } from \"@jcoder-stack/abp-react/react\";\nimport { format } from \"date-fns\";\nimport { CalendarIcon } from \"lucide-react\";\nimport { useState } from \"react\";\nimport type { DatePickerProps } from \"@/components/date-picker/date-picker\";\nimport { dateFnsLocale } from \"@/components/date-picker/date-picker\";\nimport { Button } from \"@/components/ui/button\";\nimport { Calendar } from \"@/components/ui/calendar\";\nimport { Input } from \"@/components/ui/input\";\nimport { Popover, PopoverContent, PopoverTrigger } from \"@/components/ui/popover\";\nimport { cn } from \"@/lib/utils\";\n\n/** 把 \"HH:mm\" 拼进 date 的时分(date 拷贝构造,不改动原引用)。 */\nfunction mergeTime(date: Date, time: string): Date {\n const [hours = 0, minutes = 0] = time.split(\":\").map(Number);\n const merged = new Date(date);\n merged.setHours(hours, minutes, 0, 0);\n return merged;\n}\n\nexport function DateTimePicker(props: DatePickerProps) {\n const L = useLocalization();\n const culture = useCulture();\n const [open, setOpen] = useState(false);\n const locale = dateFnsLocale(culture);\n const timeValue = props.value ? format(props.value, \"HH:mm\") : \"\";\n const label = props.value\n ? format(props.value, \"PPp\", { locale })\n : (props.placeholder ?? L(\"DatePicker:Placeholder\"));\n return (\n <Popover open={open} onOpenChange={setOpen}>\n <PopoverTrigger asChild>\n <Button\n id={props.id}\n type=\"button\"\n variant=\"outline\"\n disabled={props.disabled}\n aria-invalid={props[\"aria-invalid\"] || undefined}\n aria-required={props[\"aria-required\"] || undefined}\n // 日期加时分比单日期更长,窄容器里必然超宽;Button 的 whitespace-nowrap 会直接裁断,\n // 故文本自己 truncate,完整值走 title。占位文案没有「完整值」可揭示,不给 title。\n title={props.value ? label : undefined}\n className={cn(\n \"w-full justify-start text-left font-normal\",\n !props.value && \"text-muted-foreground\",\n )}\n >\n <CalendarIcon className=\"size-4\" />\n <span className=\"min-w-0 truncate\">{label}</span>\n </Button>\n </PopoverTrigger>\n <PopoverContent className=\"w-auto p-0\" align=\"start\">\n <Calendar\n mode=\"single\"\n selected={props.value}\n // 见 date-picker.tsx:挂载时按 value 起锚,弹层每次打开都重新定位。这里 value 会\n // 随时间输入变化,而 defaultMonth 只读挂载那一次,故改时分不会把日历甩走。\n defaultMonth={props.value}\n onSelect={(date) => {\n if (date === undefined) {\n props.onChange(undefined);\n return;\n }\n props.onChange(timeValue === \"\" ? date : mergeTime(date, timeValue));\n }}\n locale={locale}\n autoFocus\n />\n <div className=\"border-t p-3\">\n <Input\n type=\"time\"\n aria-label={L(\"DatePicker:Time\")}\n // 原生时钟指示器会弹出浏览器自绘的时分列表,与全站视觉无关且无法主题化;隐藏它,\n // 只留手输与键盘上下调整(Firefox/Safari 本就没有这个指示器,行为因此一致)。\n className=\"appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none\"\n value={timeValue}\n onChange={(event) => {\n const time = event.target.value;\n if (time === \"\") return;\n props.onChange(mergeTime(props.value ?? new Date(), time));\n }}\n />\n </div>\n </PopoverContent>\n </Popover>\n );\n}\n",
|
|
40
|
+
"type": "registry:component",
|
|
41
|
+
"target": "components/date-picker/date-time-picker.tsx"
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"path": "ui/blocks/date-picker/date-picker-messages.json",
|
|
45
|
+
"content": "{\n \"en\": {\n \"\": {\n \"DatePicker:Placeholder\": \"Pick a date\",\n \"DatePicker:RangePlaceholder\": \"Pick a date range\",\n \"DatePicker:Time\": \"Time\"\n }\n },\n \"zh-Hans\": {\n \"\": {\n \"DatePicker:Placeholder\": \"选择日期\",\n \"DatePicker:RangePlaceholder\": \"选择日期范围\",\n \"DatePicker:Time\": \"时间\"\n }\n }\n}\n",
|
|
46
|
+
"type": "registry:file",
|
|
47
|
+
"target": "components/date-picker/date-picker-messages.json"
|
|
48
|
+
}
|
|
49
|
+
],
|
|
50
|
+
"type": "registry:block"
|
|
51
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "form",
|
|
4
|
+
"title": "Sheet Form",
|
|
5
|
+
"description": "Sheet-based form shell with server field-error mapping, wired to @tanstack/react-form.",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"@tanstack/react-form",
|
|
8
|
+
"@jcoder-stack/abp-react",
|
|
9
|
+
"lucide-react"
|
|
10
|
+
],
|
|
11
|
+
"registryDependencies": [
|
|
12
|
+
"sheet",
|
|
13
|
+
"button",
|
|
14
|
+
"alert",
|
|
15
|
+
"field",
|
|
16
|
+
"input",
|
|
17
|
+
"switch",
|
|
18
|
+
"select",
|
|
19
|
+
"badge",
|
|
20
|
+
"skeleton",
|
|
21
|
+
"./node_modules/@jcoder-stack/registry/public/r/combobox.json",
|
|
22
|
+
"./node_modules/@jcoder-stack/registry/public/r/date-picker.json"
|
|
23
|
+
],
|
|
24
|
+
"files": [
|
|
25
|
+
{
|
|
26
|
+
"path": "ui/blocks/form/form-hook.tsx",
|
|
27
|
+
"content": "import { createFormHook, createFormHookContexts } from \"@tanstack/react-form\";\nimport type { ReactNode } from \"react\";\nimport { lazy, Suspense } from \"react\";\nimport { Combobox } from \"@/components/combobox/combobox\";\nimport type { ComboboxOption } from \"@/components/combobox/use-combobox-options\";\nimport { formatIso, ISO_DATE, ISO_DATE_TIME, parseIso } from \"@/components/date-picker/date-io\";\nimport { FormErrorSummary } from \"@/components/form/form-error-summary\";\nimport { RequiredMark } from \"@/components/form/required-mark\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Field, FieldError, FieldLabel } from \"@/components/ui/field\";\nimport { Input } from \"@/components/ui/input\";\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from \"@/components/ui/select\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport { Switch } from \"@/components/ui/switch\";\n\nconst MultiCombobox = lazy(() =>\n import(\"@/components/combobox/multi-combobox\").then((m) => ({ default: m.MultiCombobox })),\n);\nconst DatePicker = lazy(() =>\n import(\"@/components/date-picker/date-picker\").then((m) => ({ default: m.DatePicker })),\n);\nconst DateTimePicker = lazy(() =>\n import(\"@/components/date-picker/date-time-picker\").then((m) => ({\n default: m.DateTimePicker,\n })),\n);\nconst DateRangePicker = lazy(() =>\n import(\"@/components/date-picker/date-range-picker\").then((m) => ({\n default: m.DateRangePicker,\n })),\n);\n\nexport const { fieldContext, formContext, useFieldContext, useFormContext } =\n createFormHookContexts();\n\n/** 错误元素可能是服务端原始字符串或 Standard Schema issue;FieldError 只认 {message}。 */\nfunction toFieldErrors(errors: unknown[]): { message?: string }[] {\n return errors.map((e) => (typeof e === \"string\" ? { message: e } : (e as { message?: string })));\n}\n\ninterface ServerErrorFieldMeta {\n errorMap?: Record<string, unknown>;\n}\n\n/** `clearServerSubmitErrors` 需要的最小结构面,避免依赖 TanStack Form 的完整泛型形态。 */\ninterface ServerErrorForm {\n state: { fieldMeta: Record<string, ServerErrorFieldMeta | undefined> };\n setFieldMeta: (\n name: string,\n updater: (meta: ServerErrorFieldMeta) => ServerErrorFieldMeta,\n ) => void;\n setErrorMap: (map: { onSubmit?: undefined }) => void;\n}\n\n/**\n * 绕过 @tanstack/react-form 1.33.2 死锁:onSubmitAsync 注入的字段级错误不会在重提交时自清,\n * 不清则提交被永久短路。约束:onSubmit 槽位只放服务端错误,勿给 field 另挂 onSubmit 校验器。\n */\nfunction clearServerSubmitErrors(form: ServerErrorForm): void {\n for (const name of Object.keys(form.state.fieldMeta)) {\n form.setFieldMeta(name, (meta) => ({\n ...meta,\n errorMap: { ...meta.errorMap, onSubmit: undefined },\n }));\n }\n form.setErrorMap({ onSubmit: undefined });\n}\n\nfunction FieldShell(props: {\n label: string;\n required?: boolean;\n orientation?: \"vertical\" | \"horizontal\";\n children: ReactNode;\n}) {\n const field = useFieldContext();\n const invalid = field.state.meta.errors.length > 0;\n return (\n <Field orientation={props.orientation} data-invalid={invalid ? true : undefined}>\n <FieldLabel htmlFor={field.name}>\n {props.label}\n {props.required === true && <RequiredMark />}\n </FieldLabel>\n {props.children}\n <FieldError errors={toFieldErrors(field.state.meta.errors)} />\n </Field>\n );\n}\n\nexport function TextField(props: {\n label: string;\n required?: boolean;\n type?: \"text\" | \"email\" | \"password\" | \"date\";\n disabled?: boolean;\n placeholder?: string;\n autoComplete?: string;\n}) {\n const field = useFieldContext<string>();\n return (\n <FieldShell label={props.label} required={props.required}>\n <Input\n id={field.name}\n name={field.name}\n type={props.type ?? \"text\"}\n aria-required={props.required === true || undefined}\n aria-invalid={field.state.meta.errors.length > 0 || undefined}\n value={field.state.value}\n disabled={props.disabled}\n placeholder={props.placeholder}\n autoComplete={props.autoComplete}\n onBlur={field.handleBlur}\n onChange={(event) => field.handleChange(event.target.value)}\n />\n </FieldShell>\n );\n}\n\nexport function NumberField(props: {\n label: string;\n required?: boolean;\n disabled?: boolean;\n step?: string;\n}) {\n const field = useFieldContext<number>();\n return (\n <FieldShell label={props.label} required={props.required}>\n <Input\n id={field.name}\n name={field.name}\n type=\"number\"\n step={props.step}\n aria-required={props.required === true || undefined}\n aria-invalid={field.state.meta.errors.length > 0 || undefined}\n value={Number.isNaN(field.state.value) ? \"\" : String(field.state.value)}\n disabled={props.disabled}\n onBlur={field.handleBlur}\n onChange={(event) => {\n // 空串裸转 Number 是 0,会把「清空了」写成「改成 0」;映射 NaN 让 z.number() 判失败\n const raw = event.target.value;\n field.handleChange(raw.trim() === \"\" ? Number.NaN : Number(raw));\n }}\n />\n </FieldShell>\n );\n}\n\nexport function SwitchField(props: { label: string; disabled?: boolean }) {\n const field = useFieldContext<boolean>();\n return (\n <FieldShell label={props.label} orientation=\"horizontal\">\n <Switch\n id={field.name}\n aria-invalid={field.state.meta.errors.length > 0 || undefined}\n checked={field.state.value}\n disabled={props.disabled}\n onCheckedChange={(checked: boolean) => field.handleChange(checked)}\n />\n </FieldShell>\n );\n}\n\nexport function SelectField(props: {\n label: string;\n options: { value: string; label: string }[];\n required?: boolean;\n disabled?: boolean;\n}) {\n const field = useFieldContext<string>();\n return (\n <FieldShell label={props.label} required={props.required}>\n <Select\n value={field.state.value}\n onValueChange={(value) => field.handleChange(value)}\n disabled={props.disabled}\n >\n <SelectTrigger\n id={field.name}\n className=\"w-full\"\n aria-invalid={field.state.meta.errors.length > 0 || undefined}\n >\n <SelectValue />\n </SelectTrigger>\n <SelectContent>\n {props.options.map((option) => (\n <SelectItem key={option.value} value={option.value}>\n {option.label}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </FieldShell>\n );\n}\n\nexport function DateField(props: {\n label: string;\n required?: boolean;\n disabled?: boolean;\n placeholder?: string;\n}) {\n const field = useFieldContext<string>();\n return (\n <FieldShell label={props.label} required={props.required}>\n <Suspense fallback={<Skeleton className=\"h-9 w-full\" />}>\n <DatePicker\n id={field.name}\n value={parseIso(field.state.value, ISO_DATE)}\n disabled={props.disabled}\n placeholder={props.placeholder}\n aria-required={props.required === true || undefined}\n aria-invalid={field.state.meta.errors.length > 0 || undefined}\n onChange={(date) => field.handleChange(formatIso(date, ISO_DATE))}\n />\n </Suspense>\n </FieldShell>\n );\n}\n\nexport function DateTimeField(props: {\n label: string;\n required?: boolean;\n disabled?: boolean;\n placeholder?: string;\n}) {\n const field = useFieldContext<string>();\n return (\n <FieldShell label={props.label} required={props.required}>\n <Suspense fallback={<Skeleton className=\"h-9 w-full\" />}>\n <DateTimePicker\n id={field.name}\n value={parseIso(field.state.value, ISO_DATE_TIME)}\n disabled={props.disabled}\n placeholder={props.placeholder}\n aria-required={props.required === true || undefined}\n aria-invalid={field.state.meta.errors.length > 0 || undefined}\n onChange={(date) => field.handleChange(formatIso(date, ISO_DATE_TIME))}\n />\n </Suspense>\n </FieldShell>\n );\n}\n\nexport function DateRangeField(props: { label: string; required?: boolean; disabled?: boolean }) {\n const field = useFieldContext<{ from: string; to: string }>();\n return (\n <FieldShell label={props.label} required={props.required}>\n <Suspense fallback={<Skeleton className=\"h-9 w-full\" />}>\n <DateRangePicker\n id={field.name}\n value={{\n from: parseIso(field.state.value.from, ISO_DATE),\n to: parseIso(field.state.value.to, ISO_DATE),\n }}\n disabled={props.disabled}\n aria-required={props.required === true || undefined}\n aria-invalid={field.state.meta.errors.length > 0 || undefined}\n onChange={(range) =>\n field.handleChange({\n from: formatIso(range?.from, ISO_DATE),\n to: formatIso(range?.to, ISO_DATE),\n })\n }\n />\n </Suspense>\n </FieldShell>\n );\n}\n\nexport function ComboboxField(props: {\n label: string;\n options?: ComboboxOption[];\n loadOptions?: (search: string) => Promise<ComboboxOption[]>;\n placeholder?: string;\n required?: boolean;\n disabled?: boolean;\n}) {\n const field = useFieldContext<string>();\n return (\n <FieldShell label={props.label} required={props.required}>\n <Combobox\n value={field.state.value || undefined}\n onChange={(value) => field.handleChange(value ?? \"\")}\n options={props.options ?? []}\n loadOptions={props.loadOptions}\n placeholder={props.placeholder}\n disabled={props.disabled}\n />\n </FieldShell>\n );\n}\n\nexport function MultiComboboxField(props: {\n label: string;\n options: ComboboxOption[];\n editable: boolean;\n}) {\n const field = useFieldContext<string[]>();\n return (\n <FieldShell label={props.label}>\n {props.editable ? (\n <Suspense fallback={<Skeleton className=\"h-9 w-full\" />}>\n <MultiCombobox\n values={field.state.value}\n onChange={(values) => field.handleChange(values)}\n options={props.options}\n />\n </Suspense>\n ) : (\n <div className=\"flex flex-wrap gap-1\">\n {field.state.value.length === 0 ? (\n <span className=\"text-sm text-muted-foreground\">—</span>\n ) : (\n field.state.value.map((name) => (\n <Badge key={name} variant=\"secondary\">\n {name}\n </Badge>\n ))\n )}\n </div>\n )}\n </FieldShell>\n );\n}\n\n/** onSubmitAsync 返回的 form 键落 state.errorMap.onSubmit;\\n 拆多条渲染。 */\nfunction FormErrors() {\n const form = useFormContext();\n return (\n <form.Subscribe selector={(state) => state.errorMap.onSubmit}>\n {(onSubmit) => {\n const message = typeof onSubmit === \"string\" ? onSubmit : undefined;\n return <FormErrorSummary errors={message ? message.split(\"\\n\") : []} />;\n }}\n </form.Subscribe>\n );\n}\n\nconst { useAppForm: useAppFormBase, withForm } = createFormHook({\n fieldContext,\n formContext,\n fieldComponents: {\n TextField,\n NumberField,\n SwitchField,\n SelectField,\n DateField,\n DateTimeField,\n DateRangeField,\n ComboboxField,\n MultiComboboxField,\n },\n formComponents: { FormErrors },\n});\n\n/** 提交结束后把焦点移到第一个仍有错误的字段;取不到 DOM 元素(如自定义组件未转发 id)则顺延到\n * 下一个可聚焦的错误字段。字段输入的 id 约定为 field.name(内置字段组件已遵守)。「第一个」按\n * fieldMeta 的字段注册序(≈挂载序/tab 序);条件渲染或重排字段的极端场景下未必是视觉最上者。 */\nfunction focusFirstInvalidField(form: {\n state: { fieldMeta: Record<string, { errors?: unknown[] } | undefined> };\n}): void {\n for (const [name, meta] of Object.entries(form.state.fieldMeta)) {\n if (!meta?.errors?.length) continue;\n const el = document.getElementById(name);\n if (el) {\n el.focus();\n return;\n }\n }\n}\n\nconst patchedForms = new WeakSet<object>();\n\n/**\n * 包装 useAppFormBase:提交前注入服务端错误清理(见 clearServerSubmitErrors),提交结束后聚焦\n * 首错字段(见 focusFirstInvalidField)。WeakSet 守卫而不是 useState/useRef,后者初始化器在\n * StrictMode 下双调用,保证不了只 patch 一次。\n */\nexport const useAppForm: typeof useAppFormBase = (opts) => {\n const form = useAppFormBase(opts);\n if (!patchedForms.has(form)) {\n patchedForms.add(form);\n const original = form.handleSubmit;\n form.handleSubmit = ((submitMeta?: unknown) => {\n clearServerSubmitErrors(form as unknown as ServerErrorForm);\n const result = submitMeta === undefined ? original() : original(submitMeta as never);\n return result.then(() => {\n focusFirstInvalidField(form as unknown as Parameters<typeof focusFirstInvalidField>[0]);\n });\n }) as typeof form.handleSubmit;\n }\n return form;\n};\n\nexport { withForm };\n",
|
|
28
|
+
"type": "registry:component",
|
|
29
|
+
"target": "components/form/form-hook.tsx"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"path": "ui/blocks/form/sheet-form.tsx",
|
|
33
|
+
"content": "import { useLocalization } from \"@jcoder-stack/abp-react/react\";\nimport { Pencil } from \"lucide-react\";\nimport type { ReactNode } from \"react\";\nimport { SubmitButton } from \"@/components/form/submit-button\";\nimport { Button } from \"@/components/ui/button\";\nimport { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from \"@/components/ui/sheet\";\n\nexport type SheetFormMode = \"create\" | \"edit\" | \"view\";\n\nexport interface SheetFormProps {\n mode: SheetFormMode;\n open: boolean;\n onOpenChange: (open: boolean) => void;\n title: string;\n onSubmit?: () => void;\n canEdit?: boolean;\n onEdit?: () => void;\n pending?: boolean;\n children: ReactNode;\n}\n\n/** 三态侧滑表单容器(对标 DrawerForm)。 */\nexport function SheetForm(props: SheetFormProps) {\n const L = useLocalization();\n const editable = props.mode !== \"view\";\n return (\n <Sheet open={props.open} onOpenChange={props.onOpenChange}>\n <SheetContent className=\"flex w-full flex-col gap-0 sm:max-w-md\">\n <SheetHeader className=\"flex-row items-center justify-between gap-2 pr-12\">\n <SheetTitle>{props.title}</SheetTitle>\n {!editable && props.canEdit === true && (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon\"\n className=\"size-8\"\n aria-label={L(\"Form:Edit\")}\n onClick={props.onEdit}\n >\n <Pencil />\n </Button>\n )}\n </SheetHeader>\n <form\n className=\"flex flex-1 flex-col overflow-y-auto\"\n onSubmit={(event) => {\n event.preventDefault();\n props.onSubmit?.();\n }}\n >\n <div className=\"flex-1 space-y-4 px-4 py-2\">{props.children}</div>\n {editable && (\n <SheetFooter>\n <SubmitButton pending={props.pending} />\n <Button type=\"button\" variant=\"outline\" onClick={() => props.onOpenChange(false)}>\n {L(\"Form:Cancel\")}\n </Button>\n </SheetFooter>\n )}\n </form>\n </SheetContent>\n </Sheet>\n );\n}\n",
|
|
34
|
+
"type": "registry:component",
|
|
35
|
+
"target": "components/form/sheet-form.tsx"
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"path": "ui/blocks/form/form-section.tsx",
|
|
39
|
+
"content": "import type { ReactNode } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n/** 放进 `<div className=\"divide-y rounded-lg border bg-card\">` 容器里连排,分区线由容器 divide-y 画。 */\nexport function FormSection(props: {\n title: string;\n description?: string;\n children: ReactNode;\n className?: string;\n}) {\n return (\n <div className={cn(\"grid gap-4 p-6 md:grid-cols-[220px_1fr] md:gap-8\", props.className)}>\n <div>\n <h3 className=\"text-sm font-semibold\">{props.title}</h3>\n {props.description !== undefined && (\n <p className=\"mt-1.5 text-xs leading-relaxed text-muted-foreground\">\n {props.description}\n </p>\n )}\n </div>\n <div className=\"min-w-0 space-y-4\">{props.children}</div>\n </div>\n );\n}\n",
|
|
40
|
+
"type": "registry:component",
|
|
41
|
+
"target": "components/form/form-section.tsx"
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"path": "ui/blocks/form/required-mark.tsx",
|
|
45
|
+
"content": "import { cn } from \"@/lib/utils\";\n\n/**\n * 必填字段 label 后的星号标记。仅视觉提示(`aria-hidden`),真正的必填语义由 input 自身的\n * `required`/`aria-required` 承载,标记不再对读屏播报,避免「星号 + required」重复念两遍。\n */\nexport function RequiredMark({ className }: { className?: string }) {\n return (\n <span aria-hidden className={cn(\"ml-0.5 select-none text-destructive\", className)}>\n *\n </span>\n );\n}\n",
|
|
46
|
+
"type": "registry:component",
|
|
47
|
+
"target": "components/form/required-mark.tsx"
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"path": "ui/blocks/form/server-errors.ts",
|
|
51
|
+
"content": "export type FieldErrors = { field?: string; message: string }[];\n\n/** `onSubmitAsync` 校验器的返回形状:`form` 落表单级(state.errorMap.onSubmit)、`fields` 落各字段(field.state.meta.errors)。 */\nexport interface ServerErrorMap {\n form?: string;\n fields: Record<string, string>;\n}\n\n/** 通用 FieldErrors → onSubmitAsync 返回形状;空输入返回 null(表示校验通过)。 */\nexport function toErrorMap(errors: FieldErrors): ServerErrorMap | null {\n if (errors.length === 0) return null;\n const fields: Record<string, string> = {};\n const formLevel: string[] = [];\n for (const error of errors) {\n if (error.field) {\n fields[error.field] = fields[error.field]\n ? `${fields[error.field]}\\n${error.message}`\n : error.message;\n } else {\n formLevel.push(error.message);\n }\n }\n const map: ServerErrorMap = { fields };\n if (formLevel.length > 0) map.form = formLevel.join(\"\\n\");\n return map;\n}\n\n/**\n * 把「提交 + 服务端错误落字段」封装成一个 onSubmitAsync 校验器。`mapError` 是\n * 后端协议注入点(不认识任何具体后端;ABP 版实现见 components/abp/crud/abp-form-errors)。\n * mapError 映射不出错误(非校验类失败)时回退 Error.message 作表单级错误,绝不静默吞掉。\n */\nexport function serverSubmitValidator<TValue>(\n submit: (value: TValue) => Promise<void>,\n mapError: (error: unknown) => FieldErrors,\n) {\n return async ({ value }: { value: TValue }): Promise<ServerErrorMap | null> => {\n try {\n await submit(value);\n return null;\n } catch (error) {\n const mapped = toErrorMap(mapError(error));\n if (mapped) return mapped;\n return {\n form: error instanceof Error ? error.message : String(error),\n fields: {},\n };\n }\n };\n}\n",
|
|
52
|
+
"type": "registry:component",
|
|
53
|
+
"target": "components/form/server-errors.ts"
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"path": "ui/blocks/form/submit-button.tsx",
|
|
57
|
+
"content": "import { useLocalization } from \"@jcoder-stack/abp-react/react\";\nimport { Loader2 } from \"lucide-react\";\nimport type { ReactNode } from \"react\";\nimport { Button } from \"@/components/ui/button\";\n\nexport function SubmitButton({ pending, children }: { pending?: boolean; children?: ReactNode }) {\n const L = useLocalization();\n return (\n <Button type=\"submit\" disabled={pending}>\n {pending === true && <Loader2 className=\"animate-spin\" />}\n {children ?? L(\"Form:Save\")}\n </Button>\n );\n}\n",
|
|
58
|
+
"type": "registry:component",
|
|
59
|
+
"target": "components/form/submit-button.tsx"
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"path": "ui/blocks/form/form-error-summary.tsx",
|
|
63
|
+
"content": "import { useLocalization } from \"@jcoder-stack/abp-react/react\";\nimport { Alert, AlertDescription, AlertTitle } from \"@/components/ui/alert\";\n\nexport function FormErrorSummary({ errors }: { errors: string[] }) {\n const L = useLocalization();\n if (errors.length === 0) return null;\n return (\n <Alert variant=\"destructive\">\n <AlertTitle>{L(\"Form:SubmitFailed\")}</AlertTitle>\n <AlertDescription>\n {errors.map((message) => (\n <p key={message}>{message}</p>\n ))}\n </AlertDescription>\n </Alert>\n );\n}\n",
|
|
64
|
+
"type": "registry:component",
|
|
65
|
+
"target": "components/form/form-error-summary.tsx"
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"path": "ui/blocks/form/form-messages.json",
|
|
69
|
+
"content": "{\n \"en\": {\n \"\": {\n \"Form:Save\": \"Save\",\n \"Form:Cancel\": \"Cancel\",\n \"Form:Edit\": \"Edit\",\n \"Form:Close\": \"Close\",\n \"Form:SubmitFailed\": \"Submission failed\",\n \"Form:Required\": \"This field is required\",\n \"Form:InvalidEmail\": \"Enter a valid email address\",\n \"Form:MaxLength\": \"Up to {0} characters\"\n }\n },\n \"zh-Hans\": {\n \"\": {\n \"Form:Save\": \"保存\",\n \"Form:Cancel\": \"取消\",\n \"Form:Edit\": \"编辑\",\n \"Form:Close\": \"关闭\",\n \"Form:SubmitFailed\": \"提交失败\",\n \"Form:Required\": \"此项为必填\",\n \"Form:InvalidEmail\": \"请输入有效的邮箱地址\",\n \"Form:MaxLength\": \"最多 {0} 个字符\"\n }\n }\n}\n",
|
|
70
|
+
"type": "registry:file",
|
|
71
|
+
"target": "components/form/form-messages.json"
|
|
72
|
+
}
|
|
73
|
+
],
|
|
74
|
+
"type": "registry:block"
|
|
75
|
+
}
|