@houston-ai/layout 0.5.1
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 +49 -0
- package/package.json +22 -0
- package/src/index.ts +14 -0
- package/src/resizable.tsx +50 -0
- package/src/sidebar.tsx +147 -0
- package/src/split-view.tsx +40 -0
- package/src/styles.css +2 -0
- package/src/tab-bar.tsx +73 -0
package/README.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# @deck-ui/layout
|
|
2
|
+
|
|
3
|
+
App-level layout primitives. Sidebar for navigation, tab bar for view switching, split view for panels.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add @deck-ui/layout
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```tsx
|
|
14
|
+
import { AppSidebar, TabBar, SplitView } from "@deck-ui/layout"
|
|
15
|
+
import "@deck-ui/layout/src/styles.css"
|
|
16
|
+
|
|
17
|
+
<AppSidebar
|
|
18
|
+
logo={<Logo />}
|
|
19
|
+
items={projects}
|
|
20
|
+
selectedId={activeId}
|
|
21
|
+
onSelect={setActiveId}
|
|
22
|
+
onAdd={createProject}
|
|
23
|
+
/>
|
|
24
|
+
|
|
25
|
+
<TabBar
|
|
26
|
+
tabs={[
|
|
27
|
+
{ id: "board", label: "Board" },
|
|
28
|
+
{ id: "chat", label: "Chat", badge: 2 },
|
|
29
|
+
]}
|
|
30
|
+
activeTab={currentTab}
|
|
31
|
+
onTabChange={setCurrentTab}
|
|
32
|
+
/>
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Exports
|
|
36
|
+
|
|
37
|
+
- `AppSidebar` -- project/chat list sidebar with logo, add, delete, keyboard shortcuts
|
|
38
|
+
- `TabBar` -- horizontal tab strip with badges and action slots
|
|
39
|
+
- `SplitView` -- two-pane layout with resizable divider
|
|
40
|
+
- `ResizablePanelGroup`, `ResizablePanel`, `ResizableHandle` -- lower-level resizable primitives
|
|
41
|
+
|
|
42
|
+
## Peer Dependencies
|
|
43
|
+
|
|
44
|
+
- React 19+
|
|
45
|
+
- @deck-ui/core
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
Part of [Keel & Deck](../../README.md).
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@houston-ai/layout",
|
|
3
|
+
"version": "0.5.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"types": "src/index.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"src"
|
|
9
|
+
],
|
|
10
|
+
"peerDependencies": {
|
|
11
|
+
"react": "^19.0.0",
|
|
12
|
+
"react-dom": "^19.0.0",
|
|
13
|
+
"@houston-ai/core": "workspace:*"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"lucide-react": "^0.577.0",
|
|
17
|
+
"react-resizable-panels": "^4.7.3"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"typecheck": "tsc --noEmit"
|
|
21
|
+
}
|
|
22
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export { AppSidebar } from "./sidebar";
|
|
2
|
+
export type { SidebarProps } from "./sidebar";
|
|
3
|
+
|
|
4
|
+
export { TabBar } from "./tab-bar";
|
|
5
|
+
export type { TabBarProps } from "./tab-bar";
|
|
6
|
+
|
|
7
|
+
export { SplitView } from "./split-view";
|
|
8
|
+
export type { SplitViewProps } from "./split-view";
|
|
9
|
+
|
|
10
|
+
export {
|
|
11
|
+
ResizablePanelGroup,
|
|
12
|
+
ResizablePanel,
|
|
13
|
+
ResizableHandle,
|
|
14
|
+
} from "./resizable";
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { GripVerticalIcon } from "lucide-react";
|
|
2
|
+
import * as ResizablePrimitive from "react-resizable-panels";
|
|
3
|
+
import { cn } from "@houston-ai/core";
|
|
4
|
+
|
|
5
|
+
function ResizablePanelGroup({
|
|
6
|
+
className,
|
|
7
|
+
...props
|
|
8
|
+
}: ResizablePrimitive.GroupProps) {
|
|
9
|
+
return (
|
|
10
|
+
<ResizablePrimitive.Group
|
|
11
|
+
data-slot="resizable-panel-group"
|
|
12
|
+
className={cn(
|
|
13
|
+
"flex h-full w-full aria-[orientation=vertical]:flex-col",
|
|
14
|
+
className
|
|
15
|
+
)}
|
|
16
|
+
{...props}
|
|
17
|
+
/>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function ResizablePanel({ ...props }: ResizablePrimitive.PanelProps) {
|
|
22
|
+
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function ResizableHandle({
|
|
26
|
+
withHandle,
|
|
27
|
+
className,
|
|
28
|
+
...props
|
|
29
|
+
}: ResizablePrimitive.SeparatorProps & {
|
|
30
|
+
withHandle?: boolean;
|
|
31
|
+
}) {
|
|
32
|
+
return (
|
|
33
|
+
<ResizablePrimitive.Separator
|
|
34
|
+
data-slot="resizable-handle"
|
|
35
|
+
className={cn(
|
|
36
|
+
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:outline-hidden aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:after:left-0 aria-[orientation=horizontal]:after:h-1 aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:translate-x-0 aria-[orientation=horizontal]:after:-translate-y-1/2 [&[aria-orientation=horizontal]>div]:rotate-90",
|
|
37
|
+
className
|
|
38
|
+
)}
|
|
39
|
+
{...props}
|
|
40
|
+
>
|
|
41
|
+
{withHandle && (
|
|
42
|
+
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-xs border bg-border">
|
|
43
|
+
<GripVerticalIcon className="size-2.5" />
|
|
44
|
+
</div>
|
|
45
|
+
)}
|
|
46
|
+
</ResizablePrimitive.Separator>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export { ResizableHandle, ResizablePanel, ResizablePanelGroup };
|
package/src/sidebar.tsx
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { useState, type KeyboardEvent, type ReactNode } from "react";
|
|
2
|
+
import { Plus, MoreHorizontal } from "lucide-react";
|
|
3
|
+
import { cn, DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from "@houston-ai/core";
|
|
4
|
+
|
|
5
|
+
export interface SidebarProps {
|
|
6
|
+
logo?: ReactNode;
|
|
7
|
+
items: { id: string; name: string }[];
|
|
8
|
+
selectedId?: string | null;
|
|
9
|
+
onSelect: (id: string) => void;
|
|
10
|
+
onAdd?: () => void;
|
|
11
|
+
onDelete?: (id: string) => void;
|
|
12
|
+
onRename?: (id: string, newName: string) => void;
|
|
13
|
+
sectionLabel?: string;
|
|
14
|
+
children?: ReactNode;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function AppSidebar({
|
|
18
|
+
logo,
|
|
19
|
+
items,
|
|
20
|
+
selectedId,
|
|
21
|
+
onSelect,
|
|
22
|
+
onAdd,
|
|
23
|
+
onDelete,
|
|
24
|
+
onRename,
|
|
25
|
+
sectionLabel,
|
|
26
|
+
children,
|
|
27
|
+
}: SidebarProps) {
|
|
28
|
+
const [editingId, setEditingId] = useState<string | null>(null);
|
|
29
|
+
const [editValue, setEditValue] = useState("");
|
|
30
|
+
const hasMenu = !!onDelete || !!onRename;
|
|
31
|
+
|
|
32
|
+
const startRename = (id: string, currentName: string) => {
|
|
33
|
+
setEditingId(id);
|
|
34
|
+
setEditValue(currentName);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const commitRename = (id: string) => {
|
|
38
|
+
const trimmed = editValue.trim();
|
|
39
|
+
if (trimmed && onRename) onRename(id, trimmed);
|
|
40
|
+
setEditingId(null);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const handleKeyDown = (e: KeyboardEvent, id: string) => {
|
|
44
|
+
if (onDelete && (e.key === "Delete" || e.key === "Backspace")) {
|
|
45
|
+
e.preventDefault();
|
|
46
|
+
onDelete(id);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<>
|
|
52
|
+
<aside className="w-[200px] bg-secondary flex flex-col h-full shrink-0 border-r border-border">
|
|
53
|
+
<div className="flex items-center justify-between px-4 pt-4 pb-2">
|
|
54
|
+
<div className="flex items-center gap-2">{logo}</div>
|
|
55
|
+
{onAdd && (
|
|
56
|
+
<button
|
|
57
|
+
onClick={onAdd}
|
|
58
|
+
className="size-7 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
|
59
|
+
>
|
|
60
|
+
<Plus className="size-4" strokeWidth={2} />
|
|
61
|
+
</button>
|
|
62
|
+
)}
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
{sectionLabel && (
|
|
66
|
+
<div className="px-4 pt-3 pb-1.5">
|
|
67
|
+
<span className="text-[12px] text-muted-foreground">{sectionLabel}</span>
|
|
68
|
+
</div>
|
|
69
|
+
)}
|
|
70
|
+
|
|
71
|
+
<div className="flex-1 overflow-y-auto px-2">
|
|
72
|
+
{items.map((item) => {
|
|
73
|
+
const isActive = item.id === selectedId;
|
|
74
|
+
const isEditing = editingId === item.id;
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<div
|
|
78
|
+
key={item.id}
|
|
79
|
+
className={cn(
|
|
80
|
+
"group flex items-center rounded-lg transition-colors duration-100",
|
|
81
|
+
isActive ? "bg-accent" : "hover:bg-accent/50",
|
|
82
|
+
)}
|
|
83
|
+
>
|
|
84
|
+
{isEditing ? (
|
|
85
|
+
<input
|
|
86
|
+
autoFocus
|
|
87
|
+
value={editValue}
|
|
88
|
+
onChange={(e) => setEditValue(e.target.value)}
|
|
89
|
+
onBlur={() => commitRename(item.id)}
|
|
90
|
+
onKeyDown={(e) => {
|
|
91
|
+
if (e.key === "Enter") commitRename(item.id);
|
|
92
|
+
if (e.key === "Escape") setEditingId(null);
|
|
93
|
+
}}
|
|
94
|
+
className="flex-1 px-3 py-1.5 text-[13px] bg-background outline-none border border-border rounded-lg focus:border-foreground/30"
|
|
95
|
+
/>
|
|
96
|
+
) : (
|
|
97
|
+
<button
|
|
98
|
+
onClick={() => onSelect(item.id)}
|
|
99
|
+
onKeyDown={(e) => handleKeyDown(e, item.id)}
|
|
100
|
+
className={cn(
|
|
101
|
+
"flex-1 text-left px-3 py-1.5 text-[13px] truncate min-w-0",
|
|
102
|
+
isActive ? "text-foreground" : "text-accent-foreground",
|
|
103
|
+
)}
|
|
104
|
+
>
|
|
105
|
+
{item.name}
|
|
106
|
+
</button>
|
|
107
|
+
)}
|
|
108
|
+
|
|
109
|
+
{hasMenu && !isEditing && (
|
|
110
|
+
<DropdownMenu>
|
|
111
|
+
<DropdownMenuTrigger asChild>
|
|
112
|
+
<button
|
|
113
|
+
className="shrink-0 size-7 flex items-center justify-center rounded-md text-muted-foreground opacity-0 group-hover:opacity-100 hover:text-foreground hover:bg-accent transition-all mr-1"
|
|
114
|
+
onClick={(e) => e.stopPropagation()}
|
|
115
|
+
>
|
|
116
|
+
<MoreHorizontal className="size-4" />
|
|
117
|
+
</button>
|
|
118
|
+
</DropdownMenuTrigger>
|
|
119
|
+
<DropdownMenuContent align="start" side="bottom">
|
|
120
|
+
{onRename && (
|
|
121
|
+
<DropdownMenuItem
|
|
122
|
+
onClick={() => startRename(item.id, item.name)}
|
|
123
|
+
>
|
|
124
|
+
Rename
|
|
125
|
+
</DropdownMenuItem>
|
|
126
|
+
)}
|
|
127
|
+
{onDelete && (
|
|
128
|
+
<DropdownMenuItem
|
|
129
|
+
onClick={() => onDelete(item.id)}
|
|
130
|
+
className="text-destructive focus:text-destructive"
|
|
131
|
+
>
|
|
132
|
+
Delete
|
|
133
|
+
</DropdownMenuItem>
|
|
134
|
+
)}
|
|
135
|
+
</DropdownMenuContent>
|
|
136
|
+
</DropdownMenu>
|
|
137
|
+
)}
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
})}
|
|
141
|
+
</div>
|
|
142
|
+
</aside>
|
|
143
|
+
|
|
144
|
+
{children}
|
|
145
|
+
</>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ResizablePanelGroup,
|
|
3
|
+
ResizablePanel,
|
|
4
|
+
ResizableHandle,
|
|
5
|
+
} from "./resizable";
|
|
6
|
+
import type { ReactNode } from "react";
|
|
7
|
+
|
|
8
|
+
export interface SplitViewProps {
|
|
9
|
+
left: ReactNode;
|
|
10
|
+
right: ReactNode;
|
|
11
|
+
defaultLeftSize?: number;
|
|
12
|
+
defaultRightSize?: number;
|
|
13
|
+
minLeftSize?: number;
|
|
14
|
+
minRightSize?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function SplitView({
|
|
18
|
+
left,
|
|
19
|
+
right,
|
|
20
|
+
defaultLeftSize = 55,
|
|
21
|
+
defaultRightSize = 45,
|
|
22
|
+
minLeftSize = 30,
|
|
23
|
+
minRightSize = 25,
|
|
24
|
+
}: SplitViewProps) {
|
|
25
|
+
return (
|
|
26
|
+
<ResizablePanelGroup orientation="horizontal" className="h-full">
|
|
27
|
+
<ResizablePanel
|
|
28
|
+
defaultSize={defaultLeftSize}
|
|
29
|
+
minSize={minLeftSize}
|
|
30
|
+
className="overflow-hidden"
|
|
31
|
+
>
|
|
32
|
+
{left}
|
|
33
|
+
</ResizablePanel>
|
|
34
|
+
<ResizableHandle />
|
|
35
|
+
<ResizablePanel defaultSize={defaultRightSize} minSize={minRightSize}>
|
|
36
|
+
{right}
|
|
37
|
+
</ResizablePanel>
|
|
38
|
+
</ResizablePanelGroup>
|
|
39
|
+
);
|
|
40
|
+
}
|
package/src/styles.css
ADDED
package/src/tab-bar.tsx
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { cn } from "@houston-ai/core";
|
|
3
|
+
|
|
4
|
+
export interface TabBarProps {
|
|
5
|
+
title?: string;
|
|
6
|
+
tabs: { id: string; label: string; badge?: number }[];
|
|
7
|
+
activeTab: string;
|
|
8
|
+
onTabChange: (id: string) => void;
|
|
9
|
+
actions?: ReactNode;
|
|
10
|
+
menu?: ReactNode;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function TabBar({
|
|
14
|
+
title,
|
|
15
|
+
tabs,
|
|
16
|
+
activeTab,
|
|
17
|
+
onTabChange,
|
|
18
|
+
actions,
|
|
19
|
+
menu,
|
|
20
|
+
}: TabBarProps) {
|
|
21
|
+
return (
|
|
22
|
+
<div className="shrink-0 px-5 pt-4">
|
|
23
|
+
{/* Title row + menu + actions */}
|
|
24
|
+
{(title || menu || actions) && (
|
|
25
|
+
<div className="flex items-center gap-2 mb-3">
|
|
26
|
+
{title && (
|
|
27
|
+
<h1 className="text-xl font-semibold text-foreground">{title}</h1>
|
|
28
|
+
)}
|
|
29
|
+
{menu}
|
|
30
|
+
{actions && (
|
|
31
|
+
<div className="ml-auto flex items-center gap-2">{actions}</div>
|
|
32
|
+
)}
|
|
33
|
+
</div>
|
|
34
|
+
)}
|
|
35
|
+
|
|
36
|
+
{/* Tab strip */}
|
|
37
|
+
<div className="flex items-center gap-5">
|
|
38
|
+
{tabs.map((tab) => {
|
|
39
|
+
const isActive = activeTab === tab.id;
|
|
40
|
+
return (
|
|
41
|
+
<button
|
|
42
|
+
key={tab.id}
|
|
43
|
+
onClick={() => onTabChange(tab.id)}
|
|
44
|
+
className={cn(
|
|
45
|
+
"relative flex items-center gap-1.5 pb-2.5 text-sm transition-colors duration-200",
|
|
46
|
+
isActive
|
|
47
|
+
? "text-foreground font-medium"
|
|
48
|
+
: "text-muted-foreground hover:text-foreground",
|
|
49
|
+
)}
|
|
50
|
+
>
|
|
51
|
+
{tab.label}
|
|
52
|
+
{tab.badge != null && tab.badge > 0 && (
|
|
53
|
+
<span
|
|
54
|
+
className={cn(
|
|
55
|
+
"inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full text-xs font-medium",
|
|
56
|
+
isActive
|
|
57
|
+
? "bg-primary text-primary-foreground"
|
|
58
|
+
: "bg-accent text-accent-foreground",
|
|
59
|
+
)}
|
|
60
|
+
>
|
|
61
|
+
{tab.badge}
|
|
62
|
+
</span>
|
|
63
|
+
)}
|
|
64
|
+
{isActive && (
|
|
65
|
+
<span className="absolute bottom-0 left-0 right-0 h-[2px] bg-primary rounded-full" />
|
|
66
|
+
)}
|
|
67
|
+
</button>
|
|
68
|
+
);
|
|
69
|
+
})}
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
);
|
|
73
|
+
}
|