@khal-os/ui 1.0.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/package.json +41 -0
- package/src/components/ContextMenu.tsx +130 -0
- package/src/components/avatar.tsx +71 -0
- package/src/components/badge.tsx +39 -0
- package/src/components/button.tsx +102 -0
- package/src/components/command.tsx +165 -0
- package/src/components/cost-counter.tsx +75 -0
- package/src/components/data-row.tsx +97 -0
- package/src/components/dropdown-menu.tsx +233 -0
- package/src/components/glass-card.tsx +74 -0
- package/src/components/input.tsx +48 -0
- package/src/components/khal-logo.tsx +73 -0
- package/src/components/live-feed.tsx +109 -0
- package/src/components/mesh-gradient.tsx +57 -0
- package/src/components/metric-display.tsx +93 -0
- package/src/components/note.tsx +55 -0
- package/src/components/number-flow.tsx +25 -0
- package/src/components/pill-badge.tsx +65 -0
- package/src/components/progress-bar.tsx +70 -0
- package/src/components/section-card.tsx +76 -0
- package/src/components/separator.tsx +25 -0
- package/src/components/spinner.tsx +42 -0
- package/src/components/status-dot.tsx +90 -0
- package/src/components/switch.tsx +36 -0
- package/src/components/theme-provider.tsx +58 -0
- package/src/components/theme-switcher.tsx +59 -0
- package/src/components/ticker-bar.tsx +41 -0
- package/src/components/tooltip.tsx +62 -0
- package/src/components/window-minimized-context.tsx +29 -0
- package/src/hooks/useReducedMotion.ts +21 -0
- package/src/index.ts +58 -0
- package/src/lib/animations.ts +50 -0
- package/src/primitives/collapsible-sidebar.tsx +226 -0
- package/src/primitives/dialog.tsx +76 -0
- package/src/primitives/empty-state.tsx +43 -0
- package/src/primitives/index.ts +22 -0
- package/src/primitives/list-view.tsx +155 -0
- package/src/primitives/property-panel.tsx +108 -0
- package/src/primitives/section-header.tsx +19 -0
- package/src/primitives/sidebar-nav.tsx +110 -0
- package/src/primitives/split-pane.tsx +146 -0
- package/src/primitives/status-badge.tsx +10 -0
- package/src/primitives/status-bar.tsx +100 -0
- package/src/primitives/toolbar.tsx +152 -0
- package/src/server.ts +4 -0
- package/src/stores/notification-store.ts +271 -0
- package/src/stores/theme-store.ts +33 -0
- package/src/tokens/lp-tokens.ts +36 -0
- package/src/utils.ts +6 -0
- package/tokens.css +295 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// OS Primitives — UI building blocks for desktop app chrome.
|
|
2
|
+
// Built on top of shadcn/ui components and Geist design tokens.
|
|
3
|
+
//
|
|
4
|
+
// These fill the gap between shadcn's web-focused components and the
|
|
5
|
+
// needs of a desktop-style app (toolbars, split panes, status bars, etc.).
|
|
6
|
+
//
|
|
7
|
+
// shadcn/ui components to use directly (from @/components/ui/*):
|
|
8
|
+
// Button, Input, Badge, Spinner, Separator, Tooltip, Toggle (Switch),
|
|
9
|
+
// ContextMenu, Command (CommandDialog), DropdownMenu, Note, LoadingDots,
|
|
10
|
+
// ThemeSwitcher
|
|
11
|
+
|
|
12
|
+
export { CollapsibleSidebar, useSidebar } from './collapsible-sidebar';
|
|
13
|
+
export { Dialog } from './dialog';
|
|
14
|
+
export { EmptyState } from './empty-state';
|
|
15
|
+
export { ListView } from './list-view';
|
|
16
|
+
export { PropertyPanel } from './property-panel';
|
|
17
|
+
export { SectionHeader } from './section-header';
|
|
18
|
+
export { SidebarNav } from './sidebar-nav';
|
|
19
|
+
export { SplitPane } from './split-pane';
|
|
20
|
+
export { StatusBadge } from './status-badge';
|
|
21
|
+
export { StatusBar } from './status-bar';
|
|
22
|
+
export { Toolbar } from './toolbar';
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { type KeyboardEvent, type ReactNode, useCallback, useEffect, useRef, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// ListView — selectable list with keyboard navigation.
|
|
7
|
+
//
|
|
8
|
+
// Usage:
|
|
9
|
+
// <ListView
|
|
10
|
+
// items={files}
|
|
11
|
+
// selected={selectedId}
|
|
12
|
+
// onSelect={setSelectedId}
|
|
13
|
+
// onActivate={(item) => openFile(item)}
|
|
14
|
+
// renderItem={(item, { selected, focused }) => (
|
|
15
|
+
// <div className="flex items-center gap-2">
|
|
16
|
+
// <FileIcon name={item.name} />
|
|
17
|
+
// <span>{item.name}</span>
|
|
18
|
+
// </div>
|
|
19
|
+
// )}
|
|
20
|
+
// getKey={(item) => item.id}
|
|
21
|
+
// />
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
interface ListViewProps<T> {
|
|
25
|
+
items: T[];
|
|
26
|
+
selected?: string | string[] | null;
|
|
27
|
+
onSelect?: (key: string | null) => void;
|
|
28
|
+
onActivate?: (item: T) => void;
|
|
29
|
+
renderItem: (item: T, state: { selected: boolean; focused: boolean; index: number }) => ReactNode;
|
|
30
|
+
getKey: (item: T) => string;
|
|
31
|
+
multiSelect?: boolean;
|
|
32
|
+
emptyMessage?: string;
|
|
33
|
+
className?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function ListView<T>({
|
|
37
|
+
items,
|
|
38
|
+
selected,
|
|
39
|
+
onSelect,
|
|
40
|
+
onActivate,
|
|
41
|
+
renderItem,
|
|
42
|
+
getKey,
|
|
43
|
+
multiSelect = false,
|
|
44
|
+
emptyMessage = 'No items',
|
|
45
|
+
className = '',
|
|
46
|
+
}: ListViewProps<T>) {
|
|
47
|
+
const [focusIndex, setFocusIndex] = useState(0);
|
|
48
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
49
|
+
|
|
50
|
+
const selectedSet = new Set(selected == null ? [] : Array.isArray(selected) ? selected : [selected]);
|
|
51
|
+
|
|
52
|
+
const clamp = useCallback((i: number) => Math.max(0, Math.min(items.length - 1, i)), [items.length]);
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
setFocusIndex((prev) => clamp(prev));
|
|
56
|
+
}, [items.length, clamp]);
|
|
57
|
+
|
|
58
|
+
const scrollToIndex = useCallback((i: number) => {
|
|
59
|
+
const el = listRef.current?.children[i] as HTMLElement | undefined;
|
|
60
|
+
el?.scrollIntoView({ block: 'nearest' });
|
|
61
|
+
}, []);
|
|
62
|
+
|
|
63
|
+
const handleKeyDown = useCallback(
|
|
64
|
+
(e: KeyboardEvent) => {
|
|
65
|
+
switch (e.key) {
|
|
66
|
+
case 'ArrowDown': {
|
|
67
|
+
e.preventDefault();
|
|
68
|
+
const next = clamp(focusIndex + 1);
|
|
69
|
+
setFocusIndex(next);
|
|
70
|
+
scrollToIndex(next);
|
|
71
|
+
if (!multiSelect) onSelect?.(getKey(items[next]));
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
case 'ArrowUp': {
|
|
75
|
+
e.preventDefault();
|
|
76
|
+
const prev = clamp(focusIndex - 1);
|
|
77
|
+
setFocusIndex(prev);
|
|
78
|
+
scrollToIndex(prev);
|
|
79
|
+
if (!multiSelect) onSelect?.(getKey(items[prev]));
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
case 'Home': {
|
|
83
|
+
e.preventDefault();
|
|
84
|
+
setFocusIndex(0);
|
|
85
|
+
scrollToIndex(0);
|
|
86
|
+
if (!multiSelect && items.length > 0) onSelect?.(getKey(items[0]));
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
case 'End': {
|
|
90
|
+
e.preventDefault();
|
|
91
|
+
const last = items.length - 1;
|
|
92
|
+
setFocusIndex(last);
|
|
93
|
+
scrollToIndex(last);
|
|
94
|
+
if (!multiSelect && items.length > 0) onSelect?.(getKey(items[last]));
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
case 'Enter':
|
|
98
|
+
case ' ': {
|
|
99
|
+
e.preventDefault();
|
|
100
|
+
const item = items[focusIndex];
|
|
101
|
+
if (item) {
|
|
102
|
+
onSelect?.(getKey(item));
|
|
103
|
+
if (e.key === 'Enter') onActivate?.(item);
|
|
104
|
+
}
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
[focusIndex, items, clamp, scrollToIndex, onSelect, onActivate, getKey, multiSelect]
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
if (items.length === 0) {
|
|
113
|
+
return (
|
|
114
|
+
<div className={`flex h-full items-center justify-center text-label-13 text-gray-800 ${className}`}>
|
|
115
|
+
{emptyMessage}
|
|
116
|
+
</div>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<div
|
|
122
|
+
ref={listRef}
|
|
123
|
+
className={`overflow-y-auto outline-none ${className}`}
|
|
124
|
+
role="listbox"
|
|
125
|
+
tabIndex={0}
|
|
126
|
+
onKeyDown={handleKeyDown}
|
|
127
|
+
aria-multiselectable={multiSelect}
|
|
128
|
+
>
|
|
129
|
+
{items.map((item, i) => {
|
|
130
|
+
const key = getKey(item);
|
|
131
|
+
const isSelected = selectedSet.has(key);
|
|
132
|
+
const isFocused = i === focusIndex;
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<div
|
|
136
|
+
key={key}
|
|
137
|
+
role="option"
|
|
138
|
+
aria-selected={isSelected}
|
|
139
|
+
className={`cursor-default select-none px-2 py-1 transition-colors
|
|
140
|
+
${isSelected ? 'bg-blue-700/15 text-gray-1000' : 'text-gray-1000'}
|
|
141
|
+
${isFocused && !isSelected ? 'bg-gray-alpha-100' : ''}
|
|
142
|
+
hover:bg-gray-alpha-200`}
|
|
143
|
+
onClick={() => {
|
|
144
|
+
setFocusIndex(i);
|
|
145
|
+
onSelect?.(key);
|
|
146
|
+
}}
|
|
147
|
+
onDoubleClick={() => onActivate?.(item)}
|
|
148
|
+
>
|
|
149
|
+
{renderItem(item, { selected: isSelected, focused: isFocused, index: i })}
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
152
|
+
})}
|
|
153
|
+
</div>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { type ReactNode } from 'react';
|
|
4
|
+
import { Separator } from '../components/separator';
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// PropertyPanel — key-value inspector panel (like Finder's "Get Info").
|
|
8
|
+
//
|
|
9
|
+
// Usage:
|
|
10
|
+
// <PropertyPanel title="File Info">
|
|
11
|
+
// <PropertyPanel.Section title="General">
|
|
12
|
+
// <PropertyPanel.Row label="Name" value="document.pdf" />
|
|
13
|
+
// <PropertyPanel.Row label="Size" value="2.4 MB" />
|
|
14
|
+
// <PropertyPanel.Row label="Modified" value="Feb 10, 2026" />
|
|
15
|
+
// </PropertyPanel.Section>
|
|
16
|
+
// <PropertyPanel.Section title="Permissions">
|
|
17
|
+
// <PropertyPanel.Row label="Owner" value="vercel-sandbox" />
|
|
18
|
+
// <PropertyPanel.Row label="Group" value="users" />
|
|
19
|
+
// </PropertyPanel.Section>
|
|
20
|
+
// </PropertyPanel>
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
interface PropertyPanelProps {
|
|
24
|
+
title?: string;
|
|
25
|
+
children: ReactNode;
|
|
26
|
+
className?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function PropertyPanelRoot({ title, children, className = '' }: PropertyPanelProps) {
|
|
30
|
+
return (
|
|
31
|
+
<div className={`flex flex-col overflow-y-auto ${className}`}>
|
|
32
|
+
{title && (
|
|
33
|
+
<div className="sticky top-0 z-10 border-b border-gray-alpha-200 bg-background-100 px-3 py-2">
|
|
34
|
+
<h3 className="text-label-13 font-medium text-gray-1000">{title}</h3>
|
|
35
|
+
</div>
|
|
36
|
+
)}
|
|
37
|
+
<div className="flex flex-col">{children}</div>
|
|
38
|
+
</div>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// PropertyPanel.Section
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
function PropertySection({
|
|
47
|
+
title,
|
|
48
|
+
children,
|
|
49
|
+
collapsible: _collapsible = false,
|
|
50
|
+
defaultOpen: _defaultOpen = true,
|
|
51
|
+
}: {
|
|
52
|
+
title?: string;
|
|
53
|
+
children: ReactNode;
|
|
54
|
+
collapsible?: boolean;
|
|
55
|
+
defaultOpen?: boolean;
|
|
56
|
+
}) {
|
|
57
|
+
return (
|
|
58
|
+
<div className="border-b border-gray-alpha-200 last:border-b-0">
|
|
59
|
+
{title && (
|
|
60
|
+
<div className="px-3 pt-3 pb-1">
|
|
61
|
+
<span className="text-label-13 font-medium text-gray-800">{title}</span>
|
|
62
|
+
</div>
|
|
63
|
+
)}
|
|
64
|
+
<div className={`px-3 pb-2 ${title ? '' : 'pt-2'}`}>{children}</div>
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// PropertyPanel.Row
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
interface PropertyRowProps {
|
|
74
|
+
label: string;
|
|
75
|
+
value?: ReactNode;
|
|
76
|
+
children?: ReactNode;
|
|
77
|
+
mono?: boolean;
|
|
78
|
+
copyable?: boolean;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function PropertyRow({ label, value, children, mono }: PropertyRowProps) {
|
|
82
|
+
return (
|
|
83
|
+
<div className="flex items-baseline justify-between gap-4 py-1">
|
|
84
|
+
<dt className="shrink-0 text-label-13 text-gray-800">{label}</dt>
|
|
85
|
+
<dd className={`min-w-0 truncate text-right text-label-13 text-gray-1000 ${mono ? 'font-mono' : ''}`}>
|
|
86
|
+
{children ?? value}
|
|
87
|
+
</dd>
|
|
88
|
+
</div>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// PropertyPanel.Separator
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
function PropertySeparator() {
|
|
97
|
+
return <Separator className="my-1" />;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Export
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
export const PropertyPanel = Object.assign(PropertyPanelRoot, {
|
|
105
|
+
Section: PropertySection,
|
|
106
|
+
Row: PropertyRow,
|
|
107
|
+
Separator: PropertySeparator,
|
|
108
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export function SectionHeader({
|
|
2
|
+
title,
|
|
3
|
+
description,
|
|
4
|
+
children,
|
|
5
|
+
}: {
|
|
6
|
+
title: string;
|
|
7
|
+
description?: string;
|
|
8
|
+
children?: React.ReactNode;
|
|
9
|
+
}) {
|
|
10
|
+
return (
|
|
11
|
+
<div className="mb-4 flex items-start justify-between">
|
|
12
|
+
<div>
|
|
13
|
+
<h2 className="text-copy-13 font-medium text-gray-1000">{title}</h2>
|
|
14
|
+
{description && <p className="mt-0.5 text-copy-13 text-gray-900">{description}</p>}
|
|
15
|
+
</div>
|
|
16
|
+
{children}
|
|
17
|
+
</div>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from 'react';
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// SidebarNav — reusable sidebar navigation for app shells.
|
|
7
|
+
//
|
|
8
|
+
// Extracts the repeated sidebar + tab-button pattern used across Settings,
|
|
9
|
+
// AppStore, etc. Designed to sit inside a SplitPane.Panel.
|
|
10
|
+
//
|
|
11
|
+
// Usage:
|
|
12
|
+
// <SidebarNav label="Settings" title="Settings">
|
|
13
|
+
// <SidebarNav.Item active={tab === "a"} onClick={() => setTab("a")} icon={<Icon />}>
|
|
14
|
+
// Appearance
|
|
15
|
+
// </SidebarNav.Item>
|
|
16
|
+
// </SidebarNav>
|
|
17
|
+
//
|
|
18
|
+
// // Multiple sections:
|
|
19
|
+
// <SidebarNav label="App Store">
|
|
20
|
+
// <SidebarNav.Group title="Packages">
|
|
21
|
+
// <SidebarNav.Item ...>GUI Apps</SidebarNav.Item>
|
|
22
|
+
// </SidebarNav.Group>
|
|
23
|
+
// <SidebarNav.Group title="Repos">
|
|
24
|
+
// ...
|
|
25
|
+
// </SidebarNav.Group>
|
|
26
|
+
// </SidebarNav>
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
interface SidebarNavProps {
|
|
30
|
+
children: ReactNode;
|
|
31
|
+
/** Accessible label for the <nav> element */
|
|
32
|
+
label: string;
|
|
33
|
+
/** Optional heading displayed at the top of the sidebar */
|
|
34
|
+
title?: string;
|
|
35
|
+
className?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function SidebarNavRoot({ children, label, title, className = '' }: SidebarNavProps) {
|
|
39
|
+
return (
|
|
40
|
+
<nav className={`flex flex-col py-2 ${className}`} aria-label={label}>
|
|
41
|
+
{title && (
|
|
42
|
+
<div className="px-3 pb-1">
|
|
43
|
+
<span className="text-copy-13 font-medium text-gray-800">{title}</span>
|
|
44
|
+
</div>
|
|
45
|
+
)}
|
|
46
|
+
{children}
|
|
47
|
+
</nav>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// SidebarNav.Group — optional section divider with a title
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
interface SidebarNavGroupProps {
|
|
56
|
+
children: ReactNode;
|
|
57
|
+
title?: string;
|
|
58
|
+
className?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function SidebarNavGroup({ children, title, className = '' }: SidebarNavGroupProps) {
|
|
62
|
+
return (
|
|
63
|
+
<div className={className}>
|
|
64
|
+
{title && (
|
|
65
|
+
<div className="px-3 pt-4 pb-1">
|
|
66
|
+
<span className="text-copy-13 font-medium text-gray-800">{title}</span>
|
|
67
|
+
</div>
|
|
68
|
+
)}
|
|
69
|
+
{children}
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// SidebarNav.Item — a single navigation button
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
interface SidebarNavItemProps {
|
|
79
|
+
children: ReactNode;
|
|
80
|
+
active?: boolean;
|
|
81
|
+
onClick?: () => void;
|
|
82
|
+
icon?: ReactNode;
|
|
83
|
+
/** Trailing content (e.g. a count badge) */
|
|
84
|
+
suffix?: ReactNode;
|
|
85
|
+
className?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function SidebarNavItem({ children, active, onClick, icon, suffix, className = '' }: SidebarNavItemProps) {
|
|
89
|
+
return (
|
|
90
|
+
<button
|
|
91
|
+
onClick={onClick}
|
|
92
|
+
className={`mx-1 flex items-center gap-2 rounded-md px-2 py-1 text-copy-13 transition-colors ${
|
|
93
|
+
active ? 'bg-gray-alpha-200 text-gray-1000' : 'text-gray-900 hover:bg-gray-alpha-100 hover:text-gray-1000'
|
|
94
|
+
} ${className}`}
|
|
95
|
+
>
|
|
96
|
+
{icon && <span className="shrink-0 text-gray-800 [&>svg]:h-3.5 [&>svg]:w-3.5">{icon}</span>}
|
|
97
|
+
<span className="min-w-0 truncate">{children}</span>
|
|
98
|
+
{suffix && <span className="ml-auto font-mono text-copy-13 tabular-nums text-gray-700">{suffix}</span>}
|
|
99
|
+
</button>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Export
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
export const SidebarNav = Object.assign(SidebarNavRoot, {
|
|
108
|
+
Group: SidebarNavGroup,
|
|
109
|
+
Item: SidebarNavItem,
|
|
110
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { type CSSProperties, type ReactNode, useCallback, useRef, useState, useSyncExternalStore } from 'react';
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// SplitPane — resizable two-panel layout (horizontal or vertical).
|
|
7
|
+
//
|
|
8
|
+
// Usage:
|
|
9
|
+
// <SplitPane defaultSize={240} min={120} max={400}>
|
|
10
|
+
// <SplitPane.Panel>Sidebar</SplitPane.Panel>
|
|
11
|
+
// <SplitPane.Panel>Main</SplitPane.Panel>
|
|
12
|
+
// </SplitPane>
|
|
13
|
+
//
|
|
14
|
+
// <SplitPane direction="vertical" defaultSize={200} min={100}>
|
|
15
|
+
// <SplitPane.Panel>Top</SplitPane.Panel>
|
|
16
|
+
// <SplitPane.Panel>Bottom</SplitPane.Panel>
|
|
17
|
+
// </SplitPane>
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
interface SplitPaneProps {
|
|
21
|
+
children: [ReactNode, ReactNode];
|
|
22
|
+
direction?: 'horizontal' | 'vertical';
|
|
23
|
+
defaultSize?: number;
|
|
24
|
+
min?: number;
|
|
25
|
+
max?: number;
|
|
26
|
+
/** On narrow viewports, stack panels vertically and hide the resize handle */
|
|
27
|
+
collapseBelow?: number;
|
|
28
|
+
onResize?: (size: number) => void;
|
|
29
|
+
className?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function useMediaQuery(query: string) {
|
|
33
|
+
const subscribe = useCallback(
|
|
34
|
+
(callback: () => void) => {
|
|
35
|
+
const mql = window.matchMedia(query);
|
|
36
|
+
mql.addEventListener('change', callback);
|
|
37
|
+
return () => mql.removeEventListener('change', callback);
|
|
38
|
+
},
|
|
39
|
+
[query]
|
|
40
|
+
);
|
|
41
|
+
const getSnapshot = useCallback(() => window.matchMedia(query).matches, [query]);
|
|
42
|
+
const getServerSnapshot = useCallback(() => false, []);
|
|
43
|
+
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function SplitPaneRoot({
|
|
47
|
+
children,
|
|
48
|
+
direction = 'horizontal',
|
|
49
|
+
defaultSize = 240,
|
|
50
|
+
min = 100,
|
|
51
|
+
max = 600,
|
|
52
|
+
collapseBelow = 640,
|
|
53
|
+
onResize,
|
|
54
|
+
className = '',
|
|
55
|
+
}: SplitPaneProps) {
|
|
56
|
+
const [size, setSize] = useState(defaultSize);
|
|
57
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
58
|
+
const dragging = useRef(false);
|
|
59
|
+
const startPos = useRef(0);
|
|
60
|
+
const startSize = useRef(0);
|
|
61
|
+
const isMobile = useMediaQuery(`(max-width: ${collapseBelow}px)`);
|
|
62
|
+
|
|
63
|
+
const isHorizontal = direction === 'horizontal' && !isMobile;
|
|
64
|
+
const [first, second] = children;
|
|
65
|
+
|
|
66
|
+
const onPointerDown = useCallback(
|
|
67
|
+
(e: React.PointerEvent) => {
|
|
68
|
+
e.preventDefault();
|
|
69
|
+
dragging.current = true;
|
|
70
|
+
startPos.current = isHorizontal ? e.clientX : e.clientY;
|
|
71
|
+
startSize.current = size;
|
|
72
|
+
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
|
73
|
+
},
|
|
74
|
+
[isHorizontal, size]
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
const onPointerMove = useCallback(
|
|
78
|
+
(e: React.PointerEvent) => {
|
|
79
|
+
if (!dragging.current) return;
|
|
80
|
+
const delta = (isHorizontal ? e.clientX : e.clientY) - startPos.current;
|
|
81
|
+
const next = Math.min(max, Math.max(min, startSize.current + delta));
|
|
82
|
+
setSize(next);
|
|
83
|
+
onResize?.(next);
|
|
84
|
+
},
|
|
85
|
+
[isHorizontal, min, max, onResize]
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const onPointerUp = useCallback(() => {
|
|
89
|
+
dragging.current = false;
|
|
90
|
+
}, []);
|
|
91
|
+
|
|
92
|
+
// On mobile, stack vertically with no resize handle
|
|
93
|
+
if (isMobile) {
|
|
94
|
+
return (
|
|
95
|
+
<div ref={containerRef} className={`flex flex-col h-full w-full overflow-hidden ${className}`}>
|
|
96
|
+
<div className="shrink-0 overflow-auto border-b border-gray-alpha-200">{first}</div>
|
|
97
|
+
<div className="flex-1 overflow-hidden">{second}</div>
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const firstStyle: CSSProperties = isHorizontal
|
|
103
|
+
? { width: size, minWidth: min, maxWidth: max, flexShrink: 0 }
|
|
104
|
+
: { height: size, minHeight: min, maxHeight: max, flexShrink: 0 };
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<div
|
|
108
|
+
ref={containerRef}
|
|
109
|
+
className={`flex ${isHorizontal ? 'flex-row' : 'flex-col'} h-full w-full overflow-hidden ${className}`}
|
|
110
|
+
>
|
|
111
|
+
<div style={firstStyle} className="overflow-hidden">
|
|
112
|
+
{first}
|
|
113
|
+
</div>
|
|
114
|
+
<div
|
|
115
|
+
className={`shrink-0 ${
|
|
116
|
+
isHorizontal
|
|
117
|
+
? 'w-px cursor-col-resize hover:w-0.5 hover:bg-blue-700/50 active:w-0.5 active:bg-blue-700'
|
|
118
|
+
: 'h-px cursor-row-resize hover:h-0.5 hover:bg-blue-700/50 active:h-0.5 active:bg-blue-700'
|
|
119
|
+
} bg-gray-alpha-200 transition-colors`}
|
|
120
|
+
onPointerDown={onPointerDown}
|
|
121
|
+
onPointerMove={onPointerMove}
|
|
122
|
+
onPointerUp={onPointerUp}
|
|
123
|
+
role="separator"
|
|
124
|
+
aria-orientation={isHorizontal ? 'vertical' : 'horizontal'}
|
|
125
|
+
tabIndex={0}
|
|
126
|
+
/>
|
|
127
|
+
<div className="flex-1 overflow-hidden">{second}</div>
|
|
128
|
+
</div>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// SplitPane.Panel — thin wrapper for semantic clarity
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
function Panel({ children, className = '' }: { children: ReactNode; className?: string }) {
|
|
137
|
+
return <div className={`h-full w-full overflow-auto ${className}`}>{children}</div>;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// Export as compound component
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
export const SplitPane = Object.assign(SplitPaneRoot, {
|
|
145
|
+
Panel,
|
|
146
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Badge } from '../components/badge';
|
|
2
|
+
|
|
3
|
+
export function StatusBadge({ status }: { status: string }) {
|
|
4
|
+
const variant = status === 'active' ? 'green' : status === 'creating' ? 'amber' : status === 'error' ? 'red' : 'gray';
|
|
5
|
+
return (
|
|
6
|
+
<Badge variant={variant} size="sm" contrast="low">
|
|
7
|
+
{status}
|
|
8
|
+
</Badge>
|
|
9
|
+
);
|
|
10
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { type ReactNode } from 'react';
|
|
4
|
+
import { Separator } from '../components/separator';
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// StatusBar — bottom bar for app-level status, breadcrumbs, and indicators.
|
|
8
|
+
//
|
|
9
|
+
// Usage:
|
|
10
|
+
// <StatusBar>
|
|
11
|
+
// <StatusBar.Item>Ln 42, Col 18</StatusBar.Item>
|
|
12
|
+
// <StatusBar.Separator />
|
|
13
|
+
// <StatusBar.Item>UTF-8</StatusBar.Item>
|
|
14
|
+
// <StatusBar.Spacer />
|
|
15
|
+
// <StatusBar.Item icon={<CheckCircle />} variant="success">Saved</StatusBar.Item>
|
|
16
|
+
// </StatusBar>
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
interface StatusBarProps {
|
|
20
|
+
children: ReactNode;
|
|
21
|
+
className?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function StatusBarRoot({ children, className = '' }: StatusBarProps) {
|
|
25
|
+
return (
|
|
26
|
+
<div
|
|
27
|
+
className={`flex h-6 shrink-0 items-center gap-0 border-t border-gray-alpha-200 bg-background-100 px-2 font-mono text-label-13 text-gray-900 ${className}`}
|
|
28
|
+
role="status"
|
|
29
|
+
>
|
|
30
|
+
{children}
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// StatusBar.Item
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
interface StatusBarItemProps {
|
|
40
|
+
children: ReactNode;
|
|
41
|
+
icon?: ReactNode;
|
|
42
|
+
variant?: 'default' | 'success' | 'warning' | 'error';
|
|
43
|
+
onClick?: () => void;
|
|
44
|
+
className?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const variantColors = {
|
|
48
|
+
default: 'text-gray-900',
|
|
49
|
+
success: 'text-green-900',
|
|
50
|
+
warning: 'text-amber-900',
|
|
51
|
+
error: 'text-red-900',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
function StatusBarItem({ children, icon, variant = 'default', onClick, className = '' }: StatusBarItemProps) {
|
|
55
|
+
const classes = `inline-flex items-center gap-1 px-1.5 py-0.5 rounded-sm ${variantColors[variant]} ${
|
|
56
|
+
onClick ? 'cursor-pointer hover:bg-gray-alpha-200 transition-colors' : ''
|
|
57
|
+
} ${className}`;
|
|
58
|
+
|
|
59
|
+
if (onClick) {
|
|
60
|
+
return (
|
|
61
|
+
<button className={classes} onClick={onClick}>
|
|
62
|
+
{icon}
|
|
63
|
+
{children}
|
|
64
|
+
</button>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<span className={classes}>
|
|
70
|
+
{icon}
|
|
71
|
+
{children}
|
|
72
|
+
</span>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// StatusBar.Separator
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
function StatusBarSeparator() {
|
|
81
|
+
return <Separator orientation="vertical" className="mx-0.5 h-3" />;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// StatusBar.Spacer
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
function StatusBarSpacer() {
|
|
89
|
+
return <div className="flex-1" />;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// Export
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
export const StatusBar = Object.assign(StatusBarRoot, {
|
|
97
|
+
Item: StatusBarItem,
|
|
98
|
+
Separator: StatusBarSeparator,
|
|
99
|
+
Spacer: StatusBarSpacer,
|
|
100
|
+
});
|