@tker-react/layout 0.2.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/README.md ADDED
@@ -0,0 +1,501 @@
1
+ # @tker-react/layout
2
+
3
+ React 后台管理布局框架,提供菜单、面包屑、标签页、侧边栏等布局能力。
4
+
5
+ - **无内置 adapter**:不依赖特定 UI 库,所有 UI 渲染由使用者通过 adapter 组件控制
6
+ - **React Context 共享状态**:`<Layout>` 内置 Provider,通过 `useLayout()` 管理状态
7
+ - **路由解耦**:不直接依赖路由库,通过 `setNavigateAdapter` 和 `setActivePath` 与路由层对接
8
+
9
+ ## 安装
10
+
11
+ ```bash
12
+ pnpm add @tker-react/layout
13
+ ```
14
+
15
+ 如果生产构建时样式丢失,手动导入 CSS:
16
+
17
+ ```ts
18
+ import "@tker-react/layout/layout.css";
19
+ ```
20
+
21
+ ## 快速开始
22
+
23
+ 使用 TanStack Router 的完整示例,展示 App 入口、布局配置和路由同步。
24
+
25
+ ### 1. 创建 AppLayout
26
+
27
+ ```tsx
28
+ // layouts/AppLayout.tsx
29
+ import { Layout, useLayout } from "@tker-react/layout";
30
+ import { Outlet, useRouter, useRouterState } from "@tanstack/react-router";
31
+ import { useLayoutEffect } from "react";
32
+
33
+ export function AppLayout() {
34
+ return (
35
+ <Layout>
36
+ <SetupLayout />
37
+ <Outlet />
38
+ </Layout>
39
+ );
40
+ }
41
+
42
+ function SetupLayout() {
43
+ const layout = useLayout();
44
+ const router = useRouter();
45
+ const pathname = useRouterState({ select: (s) => s.location.pathname });
46
+ const search = useRouterState({ select: (s) => s.location.searchStr });
47
+
48
+ // adapter 注册和菜单设置必须在 useLayoutEffect 中调用
49
+ useLayoutEffect(() => {
50
+ layout.setMenuAdapter(MenuAdapter);
51
+ layout.setBreadcrumbAdapter(BreadcrumbAdapter);
52
+ layout.setTabAdapter(TabAdapter);
53
+ layout.setLogoAdapter(LogoAdapter);
54
+ layout.setToolbarAdapter(ToolbarAdapter);
55
+ layout.setUserAvatarAdapter(UserAvatarAdapter);
56
+ layout.setMenus([
57
+ { path: "/dashboard", title: "仪表盘" },
58
+ {
59
+ path: "/users",
60
+ title: "用户管理",
61
+ children: [
62
+ { path: "/users", title: "用户列表" },
63
+ { path: "/users/detail", title: "用户详情" },
64
+ ],
65
+ },
66
+ ]);
67
+ layout.setHomePath("/dashboard");
68
+ }, []);
69
+
70
+ // setNavigateAdapter 存储 ref,可以直接在 render 中调用
71
+ layout.setNavigateAdapter((path) => router.navigate({ to: path }));
72
+
73
+ useLayoutEffect(() => {
74
+ layout.setActivePath(pathname, pathname + search);
75
+ }, [pathname, search]);
76
+
77
+ return null;
78
+ }
79
+ ```
80
+
81
+ > **注意**:`setXxxAdapter`、`setMenus` 等方法内部会调用 `setState`,必须在 `useLayoutEffect`(或 `useEffect`)中调用,不能在 render 期间直接调用,否则 React 会报 "Cannot update a component while rendering" 错误。`setNavigateAdapter` 只写 ref,可以在 render 中直接调用。
82
+
83
+ ### 2. 定义路由并挂载
84
+
85
+ ```tsx
86
+ // router.tsx
87
+ import { createRootRoute, createRoute, createRouter } from "@tanstack/react-router";
88
+ import { AppLayout } from "./layouts/AppLayout";
89
+
90
+ const rootRoute = createRootRoute({ component: AppLayout });
91
+
92
+ const indexRoute = createRoute({
93
+ getParentRoute: () => rootRoute,
94
+ path: "/",
95
+ component: Dashboard,
96
+ });
97
+
98
+ const dashboardRoute = createRoute({
99
+ getParentRoute: () => rootRoute,
100
+ path: "/dashboard",
101
+ component: Dashboard,
102
+ });
103
+
104
+ const routeTree = rootRoute.addChildren([indexRoute, dashboardRoute]);
105
+
106
+ export const router = createRouter({ routeTree });
107
+ ```
108
+
109
+ ```tsx
110
+ // App.tsx
111
+ import { RouterProvider } from "@tanstack/react-router";
112
+ import { router } from "./router";
113
+
114
+ export function App() {
115
+ return <RouterProvider router={router} />;
116
+ }
117
+ ```
118
+
119
+ ### 3. 编写 adapter 组件
120
+
121
+ adapter 组件接收 Layout 传入的 props,内部使用任意 UI 库渲染。以下是使用 shadcn/ui 的完整示例。
122
+
123
+ #### Logo adapter
124
+
125
+ ```tsx
126
+ // LogoAdapter.tsx
127
+ import type { LogoAdapterProps } from "@tker-react/layout";
128
+
129
+ function LogoAdapter({ collapsed }: LogoAdapterProps) {
130
+ return collapsed ? (
131
+ <div className="flex items-center justify-center h-full px-2">
132
+ <img src="/logo-sm.svg" alt="logo" className="w-6 h-6" />
133
+ </div>
134
+ ) : (
135
+ <div className="flex items-center gap-2 px-4 h-full">
136
+ <img src="/logo.svg" alt="logo" className="w-6 h-6" />
137
+ <span className="font-semibold text-sm">Admin</span>
138
+ </div>
139
+ );
140
+ }
141
+ ```
142
+
143
+ #### Menu adapter
144
+
145
+ ```tsx
146
+ // MenuAdapter.tsx
147
+ import type { MenuAdapterProps, MenuItem } from "@tker-react/layout";
148
+ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
149
+ import { cn } from "@/lib/utils";
150
+
151
+ function MenuAdapter({ menuData, activePath, collapsed, mode, onSelect }: MenuAdapterProps) {
152
+ if (mode === "top") {
153
+ return (
154
+ <nav className="flex items-center gap-1 h-full">
155
+ {menuData.map((item) => (
156
+ <button
157
+ key={item.path}
158
+ onClick={() => onSelect(item.path)}
159
+ className={cn(
160
+ "px-3 py-1 text-sm rounded-md",
161
+ activePath === item.path ? "bg-accent text-accent-foreground" : "hover:bg-muted"
162
+ )}
163
+ >
164
+ {item.icon && <item.icon className="w-4 h-4 inline mr-1" />}
165
+ {item.title || item.path}
166
+ </button>
167
+ ))}
168
+ </nav>
169
+ );
170
+ }
171
+
172
+ return (
173
+ <nav className="flex flex-col gap-1 p-2 overflow-y-auto">
174
+ {menuData.map((item) => (
175
+ <MenuItemRenderer
176
+ key={item.path}
177
+ item={item}
178
+ activePath={activePath}
179
+ collapsed={collapsed}
180
+ onSelect={onSelect}
181
+ />
182
+ ))}
183
+ </nav>
184
+ );
185
+ }
186
+
187
+ function MenuItemRenderer({
188
+ item,
189
+ activePath,
190
+ collapsed,
191
+ onSelect,
192
+ depth = 0,
193
+ }: {
194
+ item: MenuItem;
195
+ activePath: string;
196
+ collapsed: boolean;
197
+ onSelect: (path: string) => void;
198
+ depth?: number;
199
+ }) {
200
+ const hasChildren = item.children && item.children.length > 0;
201
+
202
+ if (hasChildren) {
203
+ return (
204
+ <Collapsible>
205
+ <CollapsibleTrigger
206
+ className={cn(
207
+ "flex items-center w-full rounded-md text-sm hover:bg-muted px-2 py-1.5",
208
+ !collapsed && "gap-2"
209
+ )}
210
+ style={{ paddingLeft: collapsed ? undefined : `${8 + depth * 12}px` }}
211
+ >
212
+ {item.icon && <item.icon className="w-4 h-4 shrink-0" />}
213
+ {!collapsed && (
214
+ <>
215
+ <span className="flex-1 text-left">{item.title || item.path}</span>
216
+ <ChevronDown className="w-3 h-3" />
217
+ </>
218
+ )}
219
+ </CollapsibleTrigger>
220
+ <CollapsibleContent>
221
+ {item.children!.map((child) => (
222
+ <MenuItemRenderer
223
+ key={child.path}
224
+ item={child}
225
+ activePath={activePath}
226
+ collapsed={collapsed}
227
+ onSelect={onSelect}
228
+ depth={depth + 1}
229
+ />
230
+ ))}
231
+ </CollapsibleContent>
232
+ </Collapsible>
233
+ );
234
+ }
235
+
236
+ return (
237
+ <button
238
+ onClick={() => onSelect(item.path)}
239
+ className={cn(
240
+ "flex items-center rounded-md text-sm px-2 py-1.5",
241
+ activePath === item.path
242
+ ? "bg-accent text-accent-foreground"
243
+ : "hover:bg-muted",
244
+ !collapsed && "gap-2"
245
+ )}
246
+ style={{ paddingLeft: collapsed ? undefined : `${8 + depth * 12}px` }}
247
+ >
248
+ {item.icon && <item.icon className="w-4 h-4 shrink-0" />}
249
+ {!collapsed && (item.title || item.path)}
250
+ </button>
251
+ );
252
+ }
253
+ ```
254
+
255
+ #### Breadcrumb adapter
256
+
257
+ ```tsx
258
+ // BreadcrumbAdapter.tsx
259
+ import type { BreadcrumbAdapterProps } from "@tker-react/layout";
260
+ import {
261
+ Breadcrumb,
262
+ BreadcrumbItem,
263
+ BreadcrumbLink,
264
+ BreadcrumbList,
265
+ BreadcrumbSeparator,
266
+ } from "@/components/ui/breadcrumb";
267
+
268
+ function BreadcrumbAdapter({ breadcrumbData, onSelect }: BreadcrumbAdapterProps) {
269
+ return (
270
+ <Breadcrumb>
271
+ <BreadcrumbList>
272
+ {breadcrumbData.map((item, index) => (
273
+ <div key={item.path} className="flex items-center gap-1">
274
+ <BreadcrumbItem>
275
+ <BreadcrumbLink
276
+ asChild
277
+ className={index === breadcrumbData.length - 1 ? "text-foreground" : ""}
278
+ >
279
+ <button
280
+ onClick={() => onSelect(item.path)}
281
+ disabled={index === breadcrumbData.length - 1}
282
+ >
283
+ {item.title}
284
+ </button>
285
+ </BreadcrumbLink>
286
+ </BreadcrumbItem>
287
+ {index < breadcrumbData.length - 1 && <BreadcrumbSeparator />}
288
+ </div>
289
+ ))}
290
+ </BreadcrumbList>
291
+ </Breadcrumb>
292
+ );
293
+ }
294
+ ```
295
+
296
+ #### Tab adapter
297
+
298
+ ```tsx
299
+ // TabAdapter.tsx
300
+ import type { TabAdapterProps } from "@tker-react/layout";
301
+ import { cn } from "@/lib/utils";
302
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
303
+
304
+ function TabAdapter({
305
+ tabData,
306
+ activeTab,
307
+ onSelect,
308
+ onClose,
309
+ onCloseOther,
310
+ onCloseAll,
311
+ onRefresh,
312
+ }: TabAdapterProps) {
313
+ return (
314
+ <div className="flex items-center h-9 bg-muted border-b">
315
+ <div className="flex-1 flex overflow-x-auto scrollbar-none">
316
+ {tabData.map((tab) => (
317
+ <div
318
+ key={tab.path}
319
+ onClick={() => onSelect(tab.path)}
320
+ className={cn(
321
+ "flex items-center gap-1 px-3 py-1 text-sm cursor-pointer border-r shrink-0",
322
+ tab.path === activeTab
323
+ ? "bg-background text-foreground"
324
+ : "text-muted-foreground hover:bg-muted-foreground/10"
325
+ )}
326
+ >
327
+ {tab.icon && <tab.icon className="w-3.5 h-3.5" />}
328
+ <span>{tab.title}</span>
329
+ {!tab.fixed && (
330
+ <button
331
+ onClick={(e) => { e.stopPropagation(); onClose(tab.path); }}
332
+ className="ml-1 rounded-full hover:bg-muted p-0.5"
333
+ >
334
+ <X className="w-3 h-3" />
335
+ </button>
336
+ )}
337
+ </div>
338
+ ))}
339
+ </div>
340
+ <button onClick={onRefresh} className="p-2 hover:bg-muted shrink-0">
341
+ <RotateCw className="w-3.5 h-3.5" />
342
+ </button>
343
+ <DropdownMenu>
344
+ <DropdownMenuTrigger className="p-2 hover:bg-muted shrink-0">
345
+ <ChevronDown className="w-3.5 h-3.5" />
346
+ </DropdownMenuTrigger>
347
+ <DropdownMenuContent align="end">
348
+ <DropdownMenuItem onClick={() => onCloseOther(activeTab)}>
349
+ 关闭其他
350
+ </DropdownMenuItem>
351
+ <DropdownMenuItem onClick={() => onCloseAll()}>
352
+ 关闭全部
353
+ </DropdownMenuItem>
354
+ </DropdownMenuContent>
355
+ </DropdownMenu>
356
+ </div>
357
+ );
358
+ }
359
+ ```
360
+
361
+ #### Toolbar adapter
362
+
363
+ ```tsx
364
+ // ToolbarAdapter.tsx
365
+ import { Button } from "@/components/ui/button";
366
+
367
+ function ToolbarAdapter() {
368
+ return (
369
+ <div className="flex items-center gap-1 px-2">
370
+ <Button variant="ghost" size="icon" className="h-7 w-7">
371
+ <Search className="w-4 h-4" />
372
+ </Button>
373
+ <Button variant="ghost" size="icon" className="h-7 w-7">
374
+ <Bell className="w-4 h-4" />
375
+ </Button>
376
+ <Button variant="ghost" size="icon" className="h-7 w-7">
377
+ <Settings className="w-4 h-4" />
378
+ </Button>
379
+ </div>
380
+ );
381
+ }
382
+ ```
383
+
384
+ #### UserAvatar adapter
385
+
386
+ ```tsx
387
+ // UserAvatarAdapter.tsx
388
+ import type { UserAvatarAdapterProps } from "@tker-react/layout";
389
+ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
390
+ import {
391
+ DropdownMenu,
392
+ DropdownMenuContent,
393
+ DropdownMenuItem,
394
+ DropdownMenuSeparator,
395
+ DropdownMenuTrigger,
396
+ } from "@/components/ui/dropdown-menu";
397
+
398
+ function UserAvatarAdapter({ onSettings, onLogout }: UserAvatarAdapterProps) {
399
+ return (
400
+ <DropdownMenu>
401
+ <DropdownMenuTrigger className="outline-none">
402
+ <Avatar className="h-7 w-7 cursor-pointer">
403
+ <AvatarImage src="/avatar.png" />
404
+ <AvatarFallback>U</AvatarFallback>
405
+ </Avatar>
406
+ </DropdownMenuTrigger>
407
+ <DropdownMenuContent align="end" className="w-40">
408
+ <DropdownMenuItem onClick={onSettings}>
409
+ 设置
410
+ </DropdownMenuItem>
411
+ <DropdownMenuSeparator />
412
+ <DropdownMenuItem onClick={onLogout}>
413
+ 退出登录
414
+ </DropdownMenuItem>
415
+ </DropdownMenuContent>
416
+ </DropdownMenu>
417
+ );
418
+ }
419
+ ```
420
+
421
+ ## 使用不同路由库
422
+
423
+ 以下展示 `SetupLayout` 组件中路由同步部分的写法。
424
+
425
+ ### TanStack Router
426
+
427
+ ```tsx
428
+ // SetupLayout 中的路由同步部分
429
+ const router = useRouter();
430
+ const pathname = useRouterState({ select: (s) => s.location.pathname });
431
+ const search = useRouterState({ select: (s) => s.location.searchStr });
432
+
433
+ layout.setNavigateAdapter((path) => router.navigate({ to: path }));
434
+
435
+ useLayoutEffect(() => {
436
+ layout.setActivePath(pathname, pathname + search);
437
+ }, [pathname, search]);
438
+ ```
439
+
440
+ ### React Router v6+
441
+
442
+ ```tsx
443
+ // SetupLayout 中的路由同步部分
444
+ const location = useLocation();
445
+ const navigate = useNavigate();
446
+
447
+ layout.setNavigateAdapter((path) => navigate(path));
448
+
449
+ useLayoutEffect(() => {
450
+ layout.setActivePath(location.pathname, location.pathname + location.search);
451
+ }, [location.pathname, location.search]);
452
+ ```
453
+
454
+ ### Next.js App Router
455
+
456
+ ```tsx
457
+ // SetupLayout 中的路由同步部分
458
+ const pathname = usePathname();
459
+ const searchParams = useSearchParams();
460
+ const router = useRouter();
461
+
462
+ layout.setNavigateAdapter((path) => router.push(path));
463
+
464
+ const fullPath = pathname + (searchParams.toString() ? `?${searchParams}` : "");
465
+
466
+ useLayoutEffect(() => {
467
+ layout.setActivePath(pathname, fullPath);
468
+ }, [pathname, fullPath]);
469
+ ```
470
+
471
+ ## API
472
+
473
+ ### `useLayout()`
474
+
475
+ | 方法 | 说明 |
476
+ |------|------|
477
+ | `setMenus(data)` | 设置菜单数据 |
478
+ | `setActivePath(path, fullPath?)` | 设置当前激活路径,自动触发面包屑更新和标签页打开 |
479
+ | `setMenuAdapter(component)` | 注册菜单适配器组件 |
480
+ | `setTabAdapter(component)` | 注册标签页适配器组件 |
481
+ | `setBreadcrumbAdapter(component)` | 注册面包屑适配器组件 |
482
+ | `setLogoAdapter(component)` | 注册 Logo 适配器组件 |
483
+ | `setToolbarAdapter(component)` | 注册工具栏适配器组件 |
484
+ | `setUserAvatarAdapter(component)` | 注册用户头像适配器组件 |
485
+ | `setNavigateAdapter(fn)` | 设置导航回调,Layout 内部点击菜单/tab/面包屑时调用 |
486
+ | `toggleCollapse()` | 切换侧边栏折叠 |
487
+ | `setLayoutMode(mode)` | 设置布局模式:`"side-menu"` 或 `"top-menu"` |
488
+ | `setHomePath(path)` | 设置首页路径,首页 tab 固定在最左侧 |
489
+ | `setMaxTabs(n)` | 设置最大标签页数量,超出后关闭最早的 |
490
+ | `getFullPath(path)` | 根据菜单 path 获取存储的完整路径 |
491
+
492
+ ### Adapter 组件 Props
493
+
494
+ | Adapter | Props |
495
+ |---------|-------|
496
+ | `menu` | `menuData`, `activePath`, `collapsed`, `mode` (`"side"|"top"`), `width`, `onSelect(path)` |
497
+ | `tab` | `tabData`, `activeTab`, `onSelect(path)`, `onClose(path)`, `onCloseOther(path?)`, `onCloseAll()`, `onRefresh()`, `onSetFixed(path, fixed)` |
498
+ | `breadcrumb` | `breadcrumbData`, `onSelect(path)` |
499
+ | `logo` | `collapsed`, `width` |
500
+ | `toolbar` | 无 props |
501
+ | `userAvatar` | `onSettings?()`, `onLogout?()` |
@@ -0,0 +1,106 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+ import { ReactNode, ComponentType } from 'react';
3
+
4
+ interface LayoutProps {
5
+ children?: ReactNode;
6
+ }
7
+ declare function Layout({ children }: LayoutProps): react_jsx_runtime.JSX.Element;
8
+
9
+ interface BreadcrumbItem {
10
+ path: string;
11
+ title: string;
12
+ }
13
+
14
+ interface MenuItem {
15
+ path: string;
16
+ title?: string;
17
+ label?: string;
18
+ name?: string;
19
+ icon?: ComponentType | string;
20
+ children?: MenuItem[];
21
+ [key: string]: any;
22
+ }
23
+
24
+ interface TabItem {
25
+ path: string;
26
+ title: string;
27
+ icon?: ComponentType | string;
28
+ fixed?: boolean;
29
+ }
30
+
31
+ interface LogoAdapterProps {
32
+ collapsed: boolean;
33
+ width: string;
34
+ }
35
+ interface MenuAdapterProps {
36
+ menuData: MenuItem[];
37
+ activePath: string;
38
+ collapsed: boolean;
39
+ mode: "side" | "top";
40
+ width: string;
41
+ onSelect: (path: string) => void;
42
+ }
43
+ interface BreadcrumbAdapterProps {
44
+ breadcrumbData: BreadcrumbItem[];
45
+ onSelect: (path: string) => void;
46
+ }
47
+ interface TabAdapterProps {
48
+ tabData: TabItem[];
49
+ activeTab: string;
50
+ onSelect: (path: string) => void;
51
+ onClose: (path: string) => void;
52
+ onCloseOther: (path?: string) => void;
53
+ onCloseAll: () => void;
54
+ onRefresh: () => void;
55
+ onSetFixed: (path: string, fixed: boolean) => void;
56
+ }
57
+ interface UserAvatarAdapterProps {
58
+ onSettings?: () => void;
59
+ onLogout?: () => void;
60
+ }
61
+ interface LayoutAdapters {
62
+ logo?: ComponentType<LogoAdapterProps>;
63
+ menu?: ComponentType<MenuAdapterProps>;
64
+ breadcrumb?: ComponentType<BreadcrumbAdapterProps>;
65
+ tab?: ComponentType<TabAdapterProps>;
66
+ toolbar?: ComponentType;
67
+ userAvatar?: ComponentType<UserAvatarAdapterProps>;
68
+ }
69
+
70
+ interface LayoutContextValue {
71
+ adapters: LayoutAdapters;
72
+ setMenuAdapter: (component: ComponentType<MenuAdapterProps>) => void;
73
+ setTabAdapter: (component: ComponentType<TabAdapterProps>) => void;
74
+ setBreadcrumbAdapter: (component: ComponentType<BreadcrumbAdapterProps>) => void;
75
+ setLogoAdapter: (component: ComponentType<LogoAdapterProps>) => void;
76
+ setToolbarAdapter: (component: ComponentType) => void;
77
+ setUserAvatarAdapter: (component: ComponentType<UserAvatarAdapterProps>) => void;
78
+ menuData: MenuItem[];
79
+ activePath: string;
80
+ collapsed: boolean;
81
+ expandedWidth: string;
82
+ collapsedWidth: string;
83
+ layoutMode: "side-menu" | "top-menu";
84
+ setMenus: (data: MenuItem[]) => void;
85
+ setActivePath: (path: string, fullPath?: string) => void;
86
+ toggleCollapse: () => void;
87
+ setCollapsed: (collapsed: boolean) => void;
88
+ setExpandedWidth: (width: string) => void;
89
+ setCollapsedWidth: (width: string) => void;
90
+ setLayoutMode: (mode: "side-menu" | "top-menu") => void;
91
+ isConcretePage: (path: string) => boolean;
92
+ getFullPath: (path: string) => string;
93
+ setNavigateAdapter: (fn: (path: string) => void) => void;
94
+ navigate: (path: string) => void;
95
+ homePath: string;
96
+ setHomePath: (path: string) => void;
97
+ maxTabs: number;
98
+ setMaxTabs: (max: number) => void;
99
+ }
100
+ declare function useLayoutContext(): LayoutContextValue;
101
+ declare function LayoutProvider({ children }: {
102
+ children: ReactNode;
103
+ }): react_jsx_runtime.JSX.Element;
104
+
105
+ export { Layout, LayoutProvider, useLayoutContext as useLayout, useLayoutContext };
106
+ export type { BreadcrumbAdapterProps, BreadcrumbItem, LayoutAdapters, LogoAdapterProps, MenuAdapterProps, MenuItem, TabAdapterProps, TabItem, UserAvatarAdapterProps };
@@ -0,0 +1,106 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+ import { ReactNode, ComponentType } from 'react';
3
+
4
+ interface LayoutProps {
5
+ children?: ReactNode;
6
+ }
7
+ declare function Layout({ children }: LayoutProps): react_jsx_runtime.JSX.Element;
8
+
9
+ interface BreadcrumbItem {
10
+ path: string;
11
+ title: string;
12
+ }
13
+
14
+ interface MenuItem {
15
+ path: string;
16
+ title?: string;
17
+ label?: string;
18
+ name?: string;
19
+ icon?: ComponentType | string;
20
+ children?: MenuItem[];
21
+ [key: string]: any;
22
+ }
23
+
24
+ interface TabItem {
25
+ path: string;
26
+ title: string;
27
+ icon?: ComponentType | string;
28
+ fixed?: boolean;
29
+ }
30
+
31
+ interface LogoAdapterProps {
32
+ collapsed: boolean;
33
+ width: string;
34
+ }
35
+ interface MenuAdapterProps {
36
+ menuData: MenuItem[];
37
+ activePath: string;
38
+ collapsed: boolean;
39
+ mode: "side" | "top";
40
+ width: string;
41
+ onSelect: (path: string) => void;
42
+ }
43
+ interface BreadcrumbAdapterProps {
44
+ breadcrumbData: BreadcrumbItem[];
45
+ onSelect: (path: string) => void;
46
+ }
47
+ interface TabAdapterProps {
48
+ tabData: TabItem[];
49
+ activeTab: string;
50
+ onSelect: (path: string) => void;
51
+ onClose: (path: string) => void;
52
+ onCloseOther: (path?: string) => void;
53
+ onCloseAll: () => void;
54
+ onRefresh: () => void;
55
+ onSetFixed: (path: string, fixed: boolean) => void;
56
+ }
57
+ interface UserAvatarAdapterProps {
58
+ onSettings?: () => void;
59
+ onLogout?: () => void;
60
+ }
61
+ interface LayoutAdapters {
62
+ logo?: ComponentType<LogoAdapterProps>;
63
+ menu?: ComponentType<MenuAdapterProps>;
64
+ breadcrumb?: ComponentType<BreadcrumbAdapterProps>;
65
+ tab?: ComponentType<TabAdapterProps>;
66
+ toolbar?: ComponentType;
67
+ userAvatar?: ComponentType<UserAvatarAdapterProps>;
68
+ }
69
+
70
+ interface LayoutContextValue {
71
+ adapters: LayoutAdapters;
72
+ setMenuAdapter: (component: ComponentType<MenuAdapterProps>) => void;
73
+ setTabAdapter: (component: ComponentType<TabAdapterProps>) => void;
74
+ setBreadcrumbAdapter: (component: ComponentType<BreadcrumbAdapterProps>) => void;
75
+ setLogoAdapter: (component: ComponentType<LogoAdapterProps>) => void;
76
+ setToolbarAdapter: (component: ComponentType) => void;
77
+ setUserAvatarAdapter: (component: ComponentType<UserAvatarAdapterProps>) => void;
78
+ menuData: MenuItem[];
79
+ activePath: string;
80
+ collapsed: boolean;
81
+ expandedWidth: string;
82
+ collapsedWidth: string;
83
+ layoutMode: "side-menu" | "top-menu";
84
+ setMenus: (data: MenuItem[]) => void;
85
+ setActivePath: (path: string, fullPath?: string) => void;
86
+ toggleCollapse: () => void;
87
+ setCollapsed: (collapsed: boolean) => void;
88
+ setExpandedWidth: (width: string) => void;
89
+ setCollapsedWidth: (width: string) => void;
90
+ setLayoutMode: (mode: "side-menu" | "top-menu") => void;
91
+ isConcretePage: (path: string) => boolean;
92
+ getFullPath: (path: string) => string;
93
+ setNavigateAdapter: (fn: (path: string) => void) => void;
94
+ navigate: (path: string) => void;
95
+ homePath: string;
96
+ setHomePath: (path: string) => void;
97
+ maxTabs: number;
98
+ setMaxTabs: (max: number) => void;
99
+ }
100
+ declare function useLayoutContext(): LayoutContextValue;
101
+ declare function LayoutProvider({ children }: {
102
+ children: ReactNode;
103
+ }): react_jsx_runtime.JSX.Element;
104
+
105
+ export { Layout, LayoutProvider, useLayoutContext as useLayout, useLayoutContext };
106
+ export type { BreadcrumbAdapterProps, BreadcrumbItem, LayoutAdapters, LogoAdapterProps, MenuAdapterProps, MenuItem, TabAdapterProps, TabItem, UserAvatarAdapterProps };
package/dist/index.mjs ADDED
@@ -0,0 +1,492 @@
1
+ import "./layout.css";
2
+ import { createContext, useContext, useRef, useCallback, useState, useMemo, useEffect } from 'react';
3
+
4
+ function findMenuItemByPath(menuData, path) {
5
+ for (const item of menuData) {
6
+ if (item.path === path) {
7
+ return item;
8
+ }
9
+ if (item.children) {
10
+ const found = findMenuItemByPath(item.children, path);
11
+ if (found) return found;
12
+ }
13
+ }
14
+ return null;
15
+ }
16
+ function getParentChain(menuData, path) {
17
+ const chain = [];
18
+ function traverse(items, target, ancestors) {
19
+ for (const item of items) {
20
+ if (item.path === target) {
21
+ chain.push(...ancestors, item);
22
+ return true;
23
+ }
24
+ if (item.children && traverse(item.children, target, [...ancestors, item])) {
25
+ return true;
26
+ }
27
+ }
28
+ return false;
29
+ }
30
+ traverse(menuData, path, []);
31
+ return chain;
32
+ }
33
+ function getMenuItemTitle(item) {
34
+ return item.title || item.label || item.name || item.path;
35
+ }
36
+
37
+ function generateBreadcrumb(menuData, activePath) {
38
+ const chain = getParentChain(menuData, activePath);
39
+ return chain.map((item) => ({
40
+ path: item.path,
41
+ title: getMenuItemTitle(item)
42
+ }));
43
+ }
44
+
45
+ const LayoutContext = createContext(null);
46
+ function useLayoutContext() {
47
+ const ctx = useContext(LayoutContext);
48
+ if (!ctx) {
49
+ throw new Error("useLayoutContext \u5FC5\u987B\u5728 <Layout> \u5185\u90E8\u4F7F\u7528");
50
+ }
51
+ return ctx;
52
+ }
53
+ function LayoutProvider({ children }) {
54
+ const navigateRef = useRef(null);
55
+ const setNavigateAdapter = useCallback(
56
+ (fn) => {
57
+ navigateRef.current = fn;
58
+ },
59
+ []
60
+ );
61
+ const navigate = useCallback(
62
+ (path) => {
63
+ navigateRef.current?.(path);
64
+ },
65
+ []
66
+ );
67
+ const [adapters, setAdapters] = useState({});
68
+ const setMenuAdapter = useCallback(
69
+ (c) => setAdapters((prev) => prev.menu === c ? prev : { ...prev, menu: c }),
70
+ []
71
+ );
72
+ const setTabAdapter = useCallback(
73
+ (c) => setAdapters((prev) => prev.tab === c ? prev : { ...prev, tab: c }),
74
+ []
75
+ );
76
+ const setBreadcrumbAdapter = useCallback(
77
+ (c) => setAdapters((prev) => prev.breadcrumb === c ? prev : { ...prev, breadcrumb: c }),
78
+ []
79
+ );
80
+ const setLogoAdapter = useCallback(
81
+ (c) => setAdapters((prev) => prev.logo === c ? prev : { ...prev, logo: c }),
82
+ []
83
+ );
84
+ const setToolbarAdapter = useCallback(
85
+ (c) => setAdapters((prev) => prev.toolbar === c ? prev : { ...prev, toolbar: c }),
86
+ []
87
+ );
88
+ const setUserAvatarAdapter = useCallback(
89
+ (c) => setAdapters((prev) => prev.userAvatar === c ? prev : { ...prev, userAvatar: c }),
90
+ []
91
+ );
92
+ const [menuData, setMenuData] = useState([]);
93
+ const [activePath, setActivePathState] = useState("");
94
+ const [collapsed, setCollapsedState] = useState(false);
95
+ const [expandedWidth, setExpandedWidth] = useState("200px");
96
+ const [collapsedWidth, setCollapsedWidth] = useState("64px");
97
+ const [layoutMode, setLayoutMode] = useState(
98
+ "side-menu"
99
+ );
100
+ const setMenus = useCallback((data) => setMenuData(data), []);
101
+ const toggleCollapse = useCallback(
102
+ () => setCollapsedState((p) => !p),
103
+ []
104
+ );
105
+ const pathParamsMap = useRef(/* @__PURE__ */ new Map());
106
+ const storePathParams = useCallback((path, fullPath) => {
107
+ if (fullPath && fullPath !== path) {
108
+ pathParamsMap.current.set(path, fullPath);
109
+ }
110
+ }, []);
111
+ const getFullPath = useCallback(
112
+ (path) => pathParamsMap.current.get(path) || path,
113
+ []
114
+ );
115
+ const setActivePath = useCallback(
116
+ (path, fullPath) => {
117
+ setActivePathState(path);
118
+ if (fullPath) storePathParams(path, fullPath);
119
+ },
120
+ [storePathParams]
121
+ );
122
+ const isConcretePage = useCallback(
123
+ (path) => {
124
+ const item = findMenuItemByPath(menuData, path);
125
+ return !item?.children || item.children.length === 0;
126
+ },
127
+ [menuData]
128
+ );
129
+ const [homePath, setHomePathState] = useState("");
130
+ const [maxTabs, setMaxTabsState] = useState(10);
131
+ const setHomePath = useCallback((path) => {
132
+ setHomePathState(path);
133
+ }, []);
134
+ const setMaxTabs = useCallback((max) => {
135
+ setMaxTabsState(max);
136
+ }, []);
137
+ const value = useMemo(
138
+ () => ({
139
+ adapters,
140
+ setMenuAdapter,
141
+ setTabAdapter,
142
+ setBreadcrumbAdapter,
143
+ setLogoAdapter,
144
+ setToolbarAdapter,
145
+ setUserAvatarAdapter,
146
+ menuData,
147
+ activePath,
148
+ collapsed,
149
+ expandedWidth,
150
+ collapsedWidth,
151
+ layoutMode,
152
+ setMenus,
153
+ setActivePath,
154
+ toggleCollapse,
155
+ setCollapsed: setCollapsedState,
156
+ setExpandedWidth,
157
+ setCollapsedWidth,
158
+ setLayoutMode,
159
+ isConcretePage,
160
+ getFullPath,
161
+ setNavigateAdapter,
162
+ navigate,
163
+ homePath,
164
+ setHomePath,
165
+ maxTabs,
166
+ setMaxTabs
167
+ }),
168
+ [
169
+ adapters,
170
+ setMenuAdapter,
171
+ setTabAdapter,
172
+ setBreadcrumbAdapter,
173
+ setLogoAdapter,
174
+ setToolbarAdapter,
175
+ setUserAvatarAdapter,
176
+ menuData,
177
+ activePath,
178
+ collapsed,
179
+ expandedWidth,
180
+ collapsedWidth,
181
+ layoutMode,
182
+ setMenus,
183
+ setActivePath,
184
+ toggleCollapse,
185
+ isConcretePage,
186
+ getFullPath,
187
+ setNavigateAdapter,
188
+ navigate,
189
+ homePath,
190
+ setHomePath,
191
+ maxTabs,
192
+ setMaxTabs
193
+ ]
194
+ );
195
+ return /* @__PURE__ */ React.createElement(LayoutContext.Provider, { value }, children);
196
+ }
197
+
198
+ function LayoutAside() {
199
+ const {
200
+ adapters,
201
+ collapsed,
202
+ expandedWidth,
203
+ collapsedWidth,
204
+ menuData,
205
+ activePath,
206
+ layoutMode,
207
+ getFullPath,
208
+ navigate,
209
+ toggleCollapse
210
+ } = useLayoutContext();
211
+ const menuMode = layoutMode === "top-menu" ? "top" : "side";
212
+ const handleMenuSelect = useCallback(
213
+ (path) => {
214
+ const fullPath = getFullPath(path);
215
+ if (window.location.pathname + window.location.search === fullPath) return;
216
+ navigate(fullPath);
217
+ },
218
+ [getFullPath, navigate]
219
+ );
220
+ const MenuAdapter = adapters.menu;
221
+ if (!MenuAdapter) return null;
222
+ const asideWidth = collapsed ? collapsedWidth : expandedWidth;
223
+ return /* @__PURE__ */ React.createElement("aside", { className: "tker-layout-aside", style: { width: asideWidth } }, /* @__PURE__ */ React.createElement(
224
+ MenuAdapter,
225
+ {
226
+ menuData,
227
+ activePath,
228
+ collapsed,
229
+ mode: menuMode,
230
+ width: asideWidth,
231
+ onSelect: handleMenuSelect
232
+ }
233
+ ), /* @__PURE__ */ React.createElement(
234
+ "div",
235
+ {
236
+ className: "tker-layout-aside__toggle-btn",
237
+ onClick: toggleCollapse
238
+ },
239
+ collapsed ? /* @__PURE__ */ React.createElement("svg", { viewBox: "0 0 16 16", fill: "none", width: "16", height: "16" }, /* @__PURE__ */ React.createElement(
240
+ "path",
241
+ {
242
+ d: "M5.64645 3.14645C5.45118 3.34171 5.45118 3.65829 5.64645 3.85355L9.79289 8L5.64645 12.1464C5.45118 12.3417 5.45118 12.6583 5.64645 12.8536C5.84171 13.0488 6.15829 13.0488 6.35355 12.8536L10.8536 8.35355C11.0488 8.15829 11.0488 7.84171 10.8536 7.64645L6.35355 3.14645C6.15829 2.95118 5.84171 2.95118 5.64645 3.14645Z",
243
+ fill: "currentColor"
244
+ }
245
+ )) : /* @__PURE__ */ React.createElement("svg", { viewBox: "0 0 16 16", fill: "none", width: "16", height: "16" }, /* @__PURE__ */ React.createElement(
246
+ "path",
247
+ {
248
+ d: "M10.3536 3.14645C10.5488 3.34171 10.5488 3.65829 10.3536 3.85355L6.20711 8L10.3536 12.1464C10.5488 12.3417 10.5488 12.6583 10.3536 12.8536C10.1583 13.0488 9.84171 13.0488 9.64645 12.8536L5.14645 8.35355C4.95118 8.15829 4.95118 7.84171 5.14645 7.64645L9.64645 3.14645C9.84171 2.95118 10.1583 2.95118 10.3536 3.14645Z",
249
+ fill: "currentColor"
250
+ }
251
+ ))
252
+ ));
253
+ }
254
+
255
+ function LayoutContent({ children }) {
256
+ const {
257
+ adapters,
258
+ menuData,
259
+ activePath,
260
+ layoutMode,
261
+ homePath,
262
+ maxTabs,
263
+ getFullPath,
264
+ navigate
265
+ } = useLayoutContext();
266
+ const isTopMenu = layoutMode === "top-menu";
267
+ const [tabData, setTabData] = useState([]);
268
+ const [activeTab, setActiveTab] = useState("");
269
+ const [refreshKey, setRefreshKey] = useState(0);
270
+ const menuDataRef = useRef(menuData);
271
+ menuDataRef.current = menuData;
272
+ const activeTabRef = useRef(activeTab);
273
+ activeTabRef.current = activeTab;
274
+ useEffect(() => {
275
+ if (!activePath || activePath === "/") return;
276
+ setTabData((prev) => {
277
+ const exists = prev.find((t) => t.path === activePath);
278
+ if (exists) {
279
+ setActiveTab(activePath);
280
+ return prev;
281
+ }
282
+ return openTabInData(prev, activePath, {
283
+ homePath,
284
+ maxTabs,
285
+ menuData: menuDataRef.current
286
+ });
287
+ });
288
+ setActiveTab(activePath);
289
+ }, [activePath, homePath, maxTabs]);
290
+ const handleTabSelect = useCallback(
291
+ (path) => {
292
+ const fullPath = getFullPath(path);
293
+ if (window.location.pathname + window.location.search === fullPath) return;
294
+ navigate(fullPath);
295
+ },
296
+ [getFullPath, navigate]
297
+ );
298
+ const handleTabClose = useCallback(
299
+ (path) => {
300
+ setTabData((prev) => {
301
+ const tab = prev.find((t) => t.path === path);
302
+ if (tab?.fixed) return prev;
303
+ const idx = prev.findIndex((t) => t.path === path);
304
+ if (idx === -1) return prev;
305
+ const next = [...prev];
306
+ next.splice(idx, 1);
307
+ if (activeTabRef.current === path) {
308
+ const last = next[next.length - 1];
309
+ if (last) {
310
+ setActiveTab(last.path);
311
+ navigate(getFullPath(last.path));
312
+ } else if (homePath) {
313
+ setActiveTab(homePath);
314
+ navigate(homePath);
315
+ } else {
316
+ setActiveTab("");
317
+ }
318
+ }
319
+ return next;
320
+ });
321
+ },
322
+ [getFullPath, navigate, homePath]
323
+ );
324
+ const handleCloseOtherTabs = useCallback(
325
+ (path) => {
326
+ setTabData((prev) => {
327
+ const target = path || activeTabRef.current;
328
+ const filtered = prev.filter((t) => t.path === target || t.fixed);
329
+ setActiveTab(target);
330
+ return filtered;
331
+ });
332
+ },
333
+ []
334
+ );
335
+ const handleCloseAllTabs = useCallback(() => {
336
+ setTabData((prev) => {
337
+ const fixed = prev.filter((t) => t.fixed);
338
+ const first = fixed[0];
339
+ if (first) {
340
+ setActiveTab(first.path);
341
+ navigate(getFullPath(first.path));
342
+ } else {
343
+ setActiveTab("");
344
+ }
345
+ return fixed;
346
+ });
347
+ }, [getFullPath, navigate]);
348
+ const handleSetFixed = useCallback((path, fixed) => {
349
+ setTabData(
350
+ (prev) => prev.map((t) => t.path === path ? { ...t, fixed } : t)
351
+ );
352
+ }, []);
353
+ const handleRefresh = useCallback(() => {
354
+ setRefreshKey((k) => k + 1);
355
+ }, []);
356
+ const TabAdapter = adapters.tab;
357
+ return /* @__PURE__ */ React.createElement("main", { className: "tker-layout-content" }, !isTopMenu && TabAdapter && /* @__PURE__ */ React.createElement(
358
+ TabAdapter,
359
+ {
360
+ tabData,
361
+ activeTab,
362
+ onSelect: handleTabSelect,
363
+ onClose: handleTabClose,
364
+ onCloseOther: handleCloseOtherTabs,
365
+ onCloseAll: handleCloseAllTabs,
366
+ onRefresh: handleRefresh,
367
+ onSetFixed: handleSetFixed
368
+ }
369
+ ), /* @__PURE__ */ React.createElement("div", { className: "tker-layout-content__body", key: refreshKey }, children));
370
+ }
371
+ function openTabInData(tabData, path, config) {
372
+ const { homePath, maxTabs, menuData } = config;
373
+ const next = [...tabData];
374
+ if (homePath) {
375
+ const homeIdx = next.findIndex((t) => t.path === homePath);
376
+ if (homeIdx === -1) {
377
+ const homeMenuItem = findMenuItemByPath(menuData, homePath);
378
+ const homeTitle = homeMenuItem ? homeMenuItem.title || homePath : "\u9996\u9875";
379
+ next.unshift({
380
+ path: homePath,
381
+ title: homeTitle,
382
+ icon: homeMenuItem?.icon,
383
+ fixed: true
384
+ });
385
+ } else if (homeIdx > 0) {
386
+ const [homeTab] = next.splice(homeIdx, 1);
387
+ next.unshift(homeTab);
388
+ }
389
+ }
390
+ const exists = next.find((t) => t.path === path);
391
+ if (exists) return next;
392
+ const menuItem = findMenuItemByPath(menuData, path);
393
+ const title = menuItem ? menuItem.title || menuItem.label || menuItem.name || path : path;
394
+ const icon = menuItem?.icon;
395
+ const isHome = path === homePath;
396
+ if (!isHome) {
397
+ const nonFixed = next.filter((t) => !t.fixed);
398
+ if (nonFixed.length >= maxTabs) {
399
+ const oldest = nonFixed[0];
400
+ if (oldest) {
401
+ const oldestIdx = next.findIndex((t) => t.path === oldest.path);
402
+ next.splice(oldestIdx, 1);
403
+ }
404
+ }
405
+ }
406
+ if (isHome) {
407
+ next.unshift({ path, title, icon, fixed: true });
408
+ } else {
409
+ next.push({ path, title, icon, fixed: false });
410
+ }
411
+ return next;
412
+ }
413
+
414
+ function LayoutHeader() {
415
+ const {
416
+ adapters,
417
+ collapsed,
418
+ expandedWidth,
419
+ collapsedWidth,
420
+ menuData,
421
+ activePath,
422
+ layoutMode,
423
+ getFullPath,
424
+ isConcretePage,
425
+ navigate
426
+ } = useLayoutContext();
427
+ const isTopMenu = layoutMode === "top-menu";
428
+ const isSideMenu = layoutMode === "side-menu";
429
+ const breadcrumbData = useMemo(
430
+ () => generateBreadcrumb(menuData, activePath),
431
+ [menuData, activePath]
432
+ );
433
+ const handleMenuSelect = useCallback(
434
+ (path) => {
435
+ const fullPath = getFullPath(path);
436
+ if (window.location.pathname + window.location.search === fullPath) return;
437
+ navigate(fullPath);
438
+ },
439
+ [getFullPath, navigate]
440
+ );
441
+ const handleBreadcrumbSelect = useCallback(
442
+ (path) => {
443
+ if (!isConcretePage(path)) return;
444
+ const fullPath = getFullPath(path);
445
+ if (window.location.pathname + window.location.search === fullPath) return;
446
+ navigate(fullPath);
447
+ },
448
+ [getFullPath, navigate, isConcretePage]
449
+ );
450
+ const leftContent = isTopMenu ? ["logo", "menu"] : ["logo", "breadcrumb"];
451
+ const logoWidth = isSideMenu ? collapsed ? collapsedWidth : expandedWidth : expandedWidth;
452
+ const LogoAdapter = adapters.logo;
453
+ const MenuAdapter = adapters.menu;
454
+ const BreadcrumbAdapter = adapters.breadcrumb;
455
+ const ToolbarAdapter = adapters.toolbar;
456
+ const UserAvatarAdapter = adapters.userAvatar;
457
+ return /* @__PURE__ */ React.createElement("header", { className: "tker-layout-header" }, /* @__PURE__ */ React.createElement("div", { className: "tker-layout-header__left" }, LogoAdapter && leftContent.includes("logo") && /* @__PURE__ */ React.createElement(
458
+ "div",
459
+ {
460
+ className: "tker-layout-header__left-logo",
461
+ style: { width: logoWidth }
462
+ },
463
+ /* @__PURE__ */ React.createElement(LogoAdapter, { collapsed, width: logoWidth })
464
+ ), MenuAdapter && isTopMenu && /* @__PURE__ */ React.createElement(
465
+ MenuAdapter,
466
+ {
467
+ menuData,
468
+ activePath,
469
+ collapsed: false,
470
+ mode: "top",
471
+ width: logoWidth,
472
+ onSelect: handleMenuSelect
473
+ }
474
+ ), BreadcrumbAdapter && isSideMenu && /* @__PURE__ */ React.createElement(
475
+ BreadcrumbAdapter,
476
+ {
477
+ breadcrumbData,
478
+ onSelect: handleBreadcrumbSelect
479
+ }
480
+ )), /* @__PURE__ */ React.createElement("div", { className: "tker-layout-header__right" }, ToolbarAdapter && /* @__PURE__ */ React.createElement(ToolbarAdapter, null), UserAvatarAdapter && /* @__PURE__ */ React.createElement(UserAvatarAdapter, null)));
481
+ }
482
+
483
+ function LayoutInner({ children }) {
484
+ const { layoutMode } = useLayoutContext();
485
+ const isSideMenu = layoutMode === "side-menu";
486
+ return /* @__PURE__ */ React.createElement("div", { className: "tker-layout" }, /* @__PURE__ */ React.createElement(LayoutHeader, null), /* @__PURE__ */ React.createElement("div", { className: "tker-layout__body" }, isSideMenu && /* @__PURE__ */ React.createElement(LayoutAside, null), /* @__PURE__ */ React.createElement(LayoutContent, null, children)));
487
+ }
488
+ function Layout({ children }) {
489
+ return /* @__PURE__ */ React.createElement(LayoutProvider, null, /* @__PURE__ */ React.createElement(LayoutInner, null, children));
490
+ }
491
+
492
+ export { Layout, LayoutProvider, useLayoutContext as useLayout, useLayoutContext };
@@ -0,0 +1,93 @@
1
+ .tker-layout {
2
+ display: flex;
3
+ flex-direction: column;
4
+ height: 100vh;
5
+ width: 100%;
6
+ overflow: hidden;
7
+ }
8
+
9
+ .tker-layout__body {
10
+ display: flex;
11
+ flex: 1;
12
+ overflow: hidden;
13
+ min-height: 0;
14
+ }
15
+
16
+ /* === Header === */
17
+
18
+ .tker-layout-header {
19
+ display: flex;
20
+ align-items: center;
21
+ justify-content: space-between;
22
+ height: 48px;
23
+ background: #fff;
24
+ border-bottom: 1px solid #e8e8e8;
25
+ flex-shrink: 0;
26
+ }
27
+
28
+ .tker-layout-header__left,
29
+ .tker-layout-header__right {
30
+ display: flex;
31
+ align-items: center;
32
+ gap: 12px;
33
+ }
34
+
35
+ .tker-layout-header__left-logo {
36
+ border-right: 1px solid #e8e8e8;
37
+ }
38
+
39
+ /* === Aside === */
40
+
41
+ .tker-layout-aside {
42
+ flex-shrink: 0;
43
+ height: 100%;
44
+ background: #fff;
45
+ border-right: 1px solid #e8e8e8;
46
+ transition: width 0.3s;
47
+ position: relative;
48
+ display: flex;
49
+ flex-direction: column;
50
+ overflow: hidden;
51
+ }
52
+
53
+ .tker-layout-aside__toggle-btn {
54
+ cursor: pointer;
55
+ width: 24px;
56
+ height: 24px;
57
+ position: absolute;
58
+ top: 50%;
59
+ right: -12px;
60
+ border-radius: 50%;
61
+ display: flex;
62
+ align-items: center;
63
+ justify-content: center;
64
+ font-size: 18px;
65
+ color: #333;
66
+ border: 1px solid #e8e8e8;
67
+ background-color: #fff;
68
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.06);
69
+ transform: translateY(-50%);
70
+ z-index: 100;
71
+ transition: all 0.3s;
72
+ }
73
+
74
+ .tker-layout-aside__toggle-btn:hover {
75
+ color: #18a058;
76
+ border-color: #18a058;
77
+ }
78
+
79
+ /* === Content === */
80
+
81
+ .tker-layout-content {
82
+ flex: 1;
83
+ display: flex;
84
+ flex-direction: column;
85
+ overflow: hidden;
86
+ background: rgb(250, 250, 252);
87
+ }
88
+
89
+ .tker-layout-content__body {
90
+ flex: 1;
91
+ padding: 8px;
92
+ overflow: auto;
93
+ }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@tker-react/layout",
3
+ "version": "0.2.0",
4
+ "private": false,
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "scripts": {
8
+ "build": "pnpm unbuild"
9
+ },
10
+ "files": [
11
+ "dist"
12
+ ],
13
+ "sideEffects": [
14
+ "**/*.css"
15
+ ],
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "development": "./src/index.ts",
20
+ "default": "./dist/index.mjs"
21
+ },
22
+ "./layout.css": {
23
+ "development": "./src/styles/layout.css",
24
+ "default": "./dist/layout.css"
25
+ }
26
+ },
27
+ "publishConfig": {
28
+ "access": "public",
29
+ "exports": {
30
+ ".": {
31
+ "types": "./dist/index.d.ts",
32
+ "default": "./dist/index.mjs"
33
+ },
34
+ "./layout.css": {
35
+ "default": "./dist/layout.css"
36
+ }
37
+ }
38
+ },
39
+ "peerDependencies": {
40
+ "react": ">=18",
41
+ "react-dom": ">=18"
42
+ },
43
+ "devDependencies": {
44
+ "@tker-react/tsconfig": "workspace:*",
45
+ "@types/react": "^19.0.0",
46
+ "@types/react-dom": "^19.0.0",
47
+ "react": "^19.0.0",
48
+ "react-dom": "^19.0.0"
49
+ }
50
+ }