@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 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 };
@@ -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
@@ -0,0 +1,2 @@
1
+ /* @houston-ai/layout — Tell Tailwind to scan this package's components */
2
+ @source ".";
@@ -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
+ }