tlc-claude-code 1.4.2 → 1.4.4
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/dashboard/dist/components/accessibility.test.d.ts +1 -0
- package/dashboard/dist/components/accessibility.test.js +116 -0
- package/dashboard/dist/components/layout/MobileNav.d.ts +16 -0
- package/dashboard/dist/components/layout/MobileNav.js +31 -0
- package/dashboard/dist/components/layout/MobileNav.test.d.ts +1 -0
- package/dashboard/dist/components/layout/MobileNav.test.js +111 -0
- package/dashboard/dist/components/performance.test.d.ts +1 -0
- package/dashboard/dist/components/performance.test.js +114 -0
- package/dashboard/dist/components/responsive.test.d.ts +1 -0
- package/dashboard/dist/components/responsive.test.js +114 -0
- package/dashboard/dist/components/ui/Dropdown.d.ts +22 -0
- package/dashboard/dist/components/ui/Dropdown.js +109 -0
- package/dashboard/dist/components/ui/Dropdown.test.d.ts +1 -0
- package/dashboard/dist/components/ui/Dropdown.test.js +105 -0
- package/dashboard/dist/components/ui/Modal.d.ts +13 -0
- package/dashboard/dist/components/ui/Modal.js +25 -0
- package/dashboard/dist/components/ui/Modal.test.d.ts +1 -0
- package/dashboard/dist/components/ui/Modal.test.js +91 -0
- package/dashboard/dist/components/ui/Skeleton.d.ts +32 -0
- package/dashboard/dist/components/ui/Skeleton.js +48 -0
- package/dashboard/dist/components/ui/Skeleton.test.d.ts +1 -0
- package/dashboard/dist/components/ui/Skeleton.test.js +125 -0
- package/dashboard/dist/components/ui/Toast.d.ts +32 -0
- package/dashboard/dist/components/ui/Toast.js +21 -0
- package/dashboard/dist/components/ui/Toast.test.d.ts +1 -0
- package/dashboard/dist/components/ui/Toast.test.js +118 -0
- package/dashboard/dist/hooks/useTheme.d.ts +37 -0
- package/dashboard/dist/hooks/useTheme.js +96 -0
- package/dashboard/dist/hooks/useTheme.test.d.ts +1 -0
- package/dashboard/dist/hooks/useTheme.test.js +94 -0
- package/dashboard/dist/hooks/useWebSocket.d.ts +17 -0
- package/dashboard/dist/hooks/useWebSocket.js +100 -0
- package/dashboard/dist/hooks/useWebSocket.test.d.ts +1 -0
- package/dashboard/dist/hooks/useWebSocket.test.js +115 -0
- package/dashboard/dist/stores/projectStore.d.ts +44 -0
- package/dashboard/dist/stores/projectStore.js +76 -0
- package/dashboard/dist/stores/projectStore.test.d.ts +1 -0
- package/dashboard/dist/stores/projectStore.test.js +114 -0
- package/dashboard/dist/stores/uiStore.d.ts +29 -0
- package/dashboard/dist/stores/uiStore.js +72 -0
- package/dashboard/dist/stores/uiStore.test.d.ts +1 -0
- package/dashboard/dist/stores/uiStore.test.js +93 -0
- package/dashboard/package.json +3 -3
- package/docker-compose.dev.yml +6 -1
- package/package.json +1 -1
- package/server/dashboard/index.html +1336 -779
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Text, Box } from 'ink';
|
|
3
|
+
import { describe, it, expect } from 'vitest';
|
|
4
|
+
import { render } from 'ink-testing-library';
|
|
5
|
+
// Import components to test accessibility
|
|
6
|
+
import { Button } from './ui/Button.js';
|
|
7
|
+
import { Modal } from './ui/Modal.js';
|
|
8
|
+
import { Dropdown } from './ui/Dropdown.js';
|
|
9
|
+
import { Toast } from './ui/Toast.js';
|
|
10
|
+
import { Input } from './ui/Input.js';
|
|
11
|
+
import { CommandPalette } from './CommandPalette.js';
|
|
12
|
+
describe('Accessibility', () => {
|
|
13
|
+
describe('Keyboard Navigation', () => {
|
|
14
|
+
it('Button is keyboard accessible', () => {
|
|
15
|
+
const { lastFrame } = render(_jsx(Button, { children: "Click me" }));
|
|
16
|
+
expect(lastFrame()).toContain('Click me');
|
|
17
|
+
// Buttons should render and be focusable
|
|
18
|
+
});
|
|
19
|
+
it('Modal shows keyboard hints', () => {
|
|
20
|
+
const { lastFrame } = render(_jsx(Modal, { isOpen: true, onClose: () => { }, closeable: true, children: _jsx(Text, { children: "Content" }) }));
|
|
21
|
+
expect(lastFrame()).toMatch(/esc/i);
|
|
22
|
+
});
|
|
23
|
+
it('Dropdown shows navigation hints', () => {
|
|
24
|
+
const { lastFrame } = render(_jsx(Dropdown, { options: [{ value: '1', label: 'Option 1' }], onSelect: () => { }, isOpen: true }));
|
|
25
|
+
expect(lastFrame()).toMatch(/↑|↓|enter|esc/i);
|
|
26
|
+
});
|
|
27
|
+
it('CommandPalette shows navigation hints', () => {
|
|
28
|
+
const { lastFrame } = render(_jsx(CommandPalette, { commands: [{ id: '1', name: 'Test', description: 'Test command' }], onSelect: () => { } }));
|
|
29
|
+
expect(lastFrame()).toMatch(/↑|↓|j|k|enter|esc/i);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
describe('Focus Indicators', () => {
|
|
33
|
+
it('Button shows focus state', () => {
|
|
34
|
+
const { lastFrame: focused } = render(_jsx(Button, { isFocused: true, children: "Focused" }));
|
|
35
|
+
const { lastFrame: unfocused } = render(_jsx(Button, { isFocused: false, children: "Unfocused" }));
|
|
36
|
+
// Both should render, focused may have different styling
|
|
37
|
+
expect(focused()).toContain('Focused');
|
|
38
|
+
expect(unfocused()).toContain('Unfocused');
|
|
39
|
+
});
|
|
40
|
+
it('Input shows focus state', () => {
|
|
41
|
+
const { lastFrame } = render(_jsx(Input, { placeholder: "Type here", focus: true }));
|
|
42
|
+
expect(lastFrame()).toBeDefined();
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
describe('Screen Reader Support', () => {
|
|
46
|
+
it('Modal has title for identification', () => {
|
|
47
|
+
const { lastFrame } = render(_jsx(Modal, { isOpen: true, onClose: () => { }, title: "Important Dialog", children: _jsx(Text, { children: "Content" }) }));
|
|
48
|
+
expect(lastFrame()).toContain('Important Dialog');
|
|
49
|
+
});
|
|
50
|
+
it('Toast shows variant type', () => {
|
|
51
|
+
const { lastFrame } = render(_jsx(Toast, { variant: "error", message: "Something went wrong" }));
|
|
52
|
+
expect(lastFrame()).toMatch(/error|✕|✖/i);
|
|
53
|
+
});
|
|
54
|
+
it('Toast shows message content', () => {
|
|
55
|
+
const { lastFrame } = render(_jsx(Toast, { variant: "success", message: "Operation completed" }));
|
|
56
|
+
expect(lastFrame()).toContain('Operation completed');
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
describe('Color Contrast', () => {
|
|
60
|
+
it('Primary text is visible (not dimmed by default)', () => {
|
|
61
|
+
const { lastFrame } = render(_jsx(Text, { children: "Primary text" }));
|
|
62
|
+
expect(lastFrame()).toContain('Primary text');
|
|
63
|
+
});
|
|
64
|
+
it('Muted text uses dimColor', () => {
|
|
65
|
+
const { lastFrame } = render(_jsx(Text, { dimColor: true, children: "Muted text" }));
|
|
66
|
+
expect(lastFrame()).toContain('Muted text');
|
|
67
|
+
});
|
|
68
|
+
it('Error state uses red color', () => {
|
|
69
|
+
const { lastFrame } = render(_jsx(Toast, { variant: "error", message: "Error" }));
|
|
70
|
+
// Should contain error indicator
|
|
71
|
+
expect(lastFrame()).toMatch(/✕|error/i);
|
|
72
|
+
});
|
|
73
|
+
it('Success state uses green color', () => {
|
|
74
|
+
const { lastFrame } = render(_jsx(Toast, { variant: "success", message: "Success" }));
|
|
75
|
+
expect(lastFrame()).toMatch(/✓|success/i);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
describe('Logical Tab Order', () => {
|
|
79
|
+
it('Modal content is contained', () => {
|
|
80
|
+
const { lastFrame } = render(_jsxs(Modal, { isOpen: true, onClose: () => { }, title: "Dialog", children: [_jsx(Text, { children: "First element" }), _jsx(Text, { children: "Second element" })] }));
|
|
81
|
+
const output = lastFrame() || '';
|
|
82
|
+
const firstIdx = output.indexOf('First element');
|
|
83
|
+
const secondIdx = output.indexOf('Second element');
|
|
84
|
+
// First should appear before second
|
|
85
|
+
expect(firstIdx).toBeLessThan(secondIdx);
|
|
86
|
+
});
|
|
87
|
+
it('Dropdown options in logical order', () => {
|
|
88
|
+
const { lastFrame } = render(_jsx(Dropdown, { options: [
|
|
89
|
+
{ value: '1', label: 'First' },
|
|
90
|
+
{ value: '2', label: 'Second' },
|
|
91
|
+
{ value: '3', label: 'Third' },
|
|
92
|
+
], onSelect: () => { }, isOpen: true }));
|
|
93
|
+
const output = lastFrame() || '';
|
|
94
|
+
const firstIdx = output.indexOf('First');
|
|
95
|
+
const secondIdx = output.indexOf('Second');
|
|
96
|
+
const thirdIdx = output.indexOf('Third');
|
|
97
|
+
expect(firstIdx).toBeLessThan(secondIdx);
|
|
98
|
+
expect(secondIdx).toBeLessThan(thirdIdx);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
describe('Reduced Motion Support', () => {
|
|
102
|
+
it('Skeleton uses static characters (no animation in terminal)', () => {
|
|
103
|
+
// In terminal, we use static shimmer characters
|
|
104
|
+
// Animation is simulated through character choice, not actual motion
|
|
105
|
+
const { lastFrame } = render(_jsx(Box, { children: _jsx(Text, { color: "gray", children: "\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591" }) }));
|
|
106
|
+
expect(lastFrame()).toContain('░');
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
describe('Icon-Only Buttons', () => {
|
|
110
|
+
it('Button with icon still has text for context', () => {
|
|
111
|
+
const { lastFrame } = render(_jsx(Button, { leftIcon: "+", children: "Add Item" }));
|
|
112
|
+
expect(lastFrame()).toContain('Add Item');
|
|
113
|
+
expect(lastFrame()).toContain('+');
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface NavItem {
|
|
2
|
+
key: string;
|
|
3
|
+
label: string;
|
|
4
|
+
icon: string;
|
|
5
|
+
badge?: number;
|
|
6
|
+
}
|
|
7
|
+
export interface MobileNavProps {
|
|
8
|
+
items: NavItem[];
|
|
9
|
+
activeKey: string;
|
|
10
|
+
onNavigate: (key: string) => void;
|
|
11
|
+
compact?: boolean;
|
|
12
|
+
maxItems?: number;
|
|
13
|
+
isTTY?: boolean;
|
|
14
|
+
}
|
|
15
|
+
export declare function MobileNav({ items, activeKey, onNavigate, compact, maxItems, isTTY, }: MobileNavProps): import("react/jsx-runtime").JSX.Element;
|
|
16
|
+
export default MobileNav;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text, useInput } from 'ink';
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
export function MobileNav({ items, activeKey, onNavigate, compact = false, maxItems = 5, isTTY = true, }) {
|
|
5
|
+
const [focusIndex, setFocusIndex] = useState(items.findIndex(i => i.key === activeKey));
|
|
6
|
+
// Handle keyboard navigation
|
|
7
|
+
useInput((input, key) => {
|
|
8
|
+
if (!isTTY)
|
|
9
|
+
return;
|
|
10
|
+
if (key.leftArrow || input === 'h') {
|
|
11
|
+
setFocusIndex(prev => Math.max(0, prev - 1));
|
|
12
|
+
}
|
|
13
|
+
if (key.rightArrow || input === 'l') {
|
|
14
|
+
setFocusIndex(prev => Math.min(items.length - 1, prev + 1));
|
|
15
|
+
}
|
|
16
|
+
if (key.return) {
|
|
17
|
+
const item = items[focusIndex];
|
|
18
|
+
if (item) {
|
|
19
|
+
onNavigate(item.key);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}, { isActive: isTTY });
|
|
23
|
+
const visibleItems = items.slice(0, maxItems);
|
|
24
|
+
const hasMore = items.length > maxItems;
|
|
25
|
+
return (_jsxs(Box, { borderStyle: "single", borderColor: "gray", borderTop: true, borderBottom: false, borderLeft: false, borderRight: false, paddingX: 1, justifyContent: "space-around", children: [visibleItems.map((item, idx) => {
|
|
26
|
+
const isActive = item.key === activeKey;
|
|
27
|
+
const isFocused = idx === focusIndex;
|
|
28
|
+
return (_jsxs(Box, { flexDirection: "column", alignItems: "center", marginX: 1, children: [isActive && _jsx(Text, { color: "cyan", children: "\u25CF" }), !isActive && _jsx(Text, { children: " " }), _jsx(Text, { color: isActive ? 'cyan' : isFocused ? 'white' : 'gray', children: item.icon }), item.badge !== undefined && item.badge > 0 && (_jsx(Text, { color: "red", bold: true, children: item.badge })), !compact && (_jsx(Text, { color: isActive ? 'cyan' : isFocused ? 'white' : 'gray', dimColor: !isActive && !isFocused, children: item.label.length > 8 ? item.label.slice(0, 6) + '..' : item.label }))] }, item.key));
|
|
29
|
+
}), hasMore && (_jsxs(Box, { flexDirection: "column", alignItems: "center", marginX: 1, children: [_jsx(Text, { color: "gray", children: "\u22EF" }), !compact && _jsx(Text, { color: "gray", children: "more" })] }))] }));
|
|
30
|
+
}
|
|
31
|
+
export default MobileNav;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
3
|
+
import { render } from 'ink-testing-library';
|
|
4
|
+
import { MobileNav } from './MobileNav.js';
|
|
5
|
+
const sampleItems = [
|
|
6
|
+
{ key: 'projects', label: 'Projects', icon: '📁' },
|
|
7
|
+
{ key: 'tasks', label: 'Tasks', icon: '📋' },
|
|
8
|
+
{ key: 'chat', label: 'Chat', icon: '💬' },
|
|
9
|
+
{ key: 'logs', label: 'Logs', icon: '📜' },
|
|
10
|
+
{ key: 'settings', label: 'Settings', icon: '⚙️' },
|
|
11
|
+
];
|
|
12
|
+
describe('MobileNav', () => {
|
|
13
|
+
describe('Rendering', () => {
|
|
14
|
+
it('renders navigation items', () => {
|
|
15
|
+
const { lastFrame } = render(_jsx(MobileNav, { items: sampleItems, activeKey: "projects", onNavigate: () => { } }));
|
|
16
|
+
expect(lastFrame()).toContain('Projects');
|
|
17
|
+
expect(lastFrame()).toContain('Tasks');
|
|
18
|
+
});
|
|
19
|
+
it('renders item icons', () => {
|
|
20
|
+
const { lastFrame } = render(_jsx(MobileNav, { items: sampleItems, activeKey: "projects", onNavigate: () => { } }));
|
|
21
|
+
expect(lastFrame()).toContain('📁');
|
|
22
|
+
expect(lastFrame()).toContain('📋');
|
|
23
|
+
});
|
|
24
|
+
it('renders item labels', () => {
|
|
25
|
+
const { lastFrame } = render(_jsx(MobileNav, { items: sampleItems, activeKey: "projects", onNavigate: () => { } }));
|
|
26
|
+
expect(lastFrame()).toContain('Projects');
|
|
27
|
+
expect(lastFrame()).toContain('Logs');
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
describe('Active State', () => {
|
|
31
|
+
it('shows active indicator on current item', () => {
|
|
32
|
+
const { lastFrame } = render(_jsx(MobileNav, { items: sampleItems, activeKey: "tasks", onNavigate: () => { } }));
|
|
33
|
+
// Active item should have some visual indicator
|
|
34
|
+
expect(lastFrame()).toContain('Tasks');
|
|
35
|
+
});
|
|
36
|
+
it('highlights active item differently', () => {
|
|
37
|
+
const { lastFrame: activeProjects } = render(_jsx(MobileNav, { items: sampleItems, activeKey: "projects", onNavigate: () => { } }));
|
|
38
|
+
const { lastFrame: activeTasks } = render(_jsx(MobileNav, { items: sampleItems, activeKey: "tasks", onNavigate: () => { } }));
|
|
39
|
+
// Both should render but with different active states
|
|
40
|
+
expect(activeProjects()).toContain('Projects');
|
|
41
|
+
expect(activeTasks()).toContain('Tasks');
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
describe('Navigation', () => {
|
|
45
|
+
it('calls onNavigate when item selected', () => {
|
|
46
|
+
const onNavigate = vi.fn();
|
|
47
|
+
render(_jsx(MobileNav, { items: sampleItems, activeKey: "projects", onNavigate: onNavigate }));
|
|
48
|
+
expect(onNavigate).toBeDefined();
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
describe('Compact Mode', () => {
|
|
52
|
+
it('can hide labels in compact mode', () => {
|
|
53
|
+
const { lastFrame } = render(_jsx(MobileNav, { items: sampleItems, activeKey: "projects", onNavigate: () => { }, compact: true }));
|
|
54
|
+
// Icons should still show
|
|
55
|
+
expect(lastFrame()).toContain('📁');
|
|
56
|
+
});
|
|
57
|
+
it('shows only icons when compact', () => {
|
|
58
|
+
const { lastFrame } = render(_jsx(MobileNav, { items: sampleItems, activeKey: "projects", onNavigate: () => { }, compact: true }));
|
|
59
|
+
// Should render icons
|
|
60
|
+
expect(lastFrame()).toMatch(/📁|📋|💬|📜|⚙️/);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
describe('Max Items', () => {
|
|
64
|
+
it('limits visible items', () => {
|
|
65
|
+
const manyItems = Array.from({ length: 10 }, (_, i) => ({
|
|
66
|
+
key: `item${i}`,
|
|
67
|
+
label: `Item ${i}`,
|
|
68
|
+
icon: '●',
|
|
69
|
+
}));
|
|
70
|
+
const { lastFrame } = render(_jsx(MobileNav, { items: manyItems, activeKey: "item0", onNavigate: () => { }, maxItems: 5 }));
|
|
71
|
+
// Should show limited items or "more" indicator
|
|
72
|
+
expect(lastFrame()).toBeDefined();
|
|
73
|
+
});
|
|
74
|
+
it('shows overflow menu when items exceed max', () => {
|
|
75
|
+
const manyItems = Array.from({ length: 8 }, (_, i) => ({
|
|
76
|
+
key: `item${i}`,
|
|
77
|
+
label: `Item ${i}`,
|
|
78
|
+
icon: '●',
|
|
79
|
+
}));
|
|
80
|
+
const { lastFrame } = render(_jsx(MobileNav, { items: manyItems, activeKey: "item0", onNavigate: () => { }, maxItems: 4 }));
|
|
81
|
+
// Should show "more" or overflow indicator
|
|
82
|
+
expect(lastFrame()).toMatch(/more|\.{3}|»|⋯/i);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
describe('Badge Support', () => {
|
|
86
|
+
it('shows badge on item', () => {
|
|
87
|
+
const itemsWithBadge = [
|
|
88
|
+
{ key: 'tasks', label: 'Tasks', icon: '📋', badge: 3 },
|
|
89
|
+
...sampleItems.slice(1),
|
|
90
|
+
];
|
|
91
|
+
const { lastFrame } = render(_jsx(MobileNav, { items: itemsWithBadge, activeKey: "tasks", onNavigate: () => { } }));
|
|
92
|
+
expect(lastFrame()).toContain('3');
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
describe('Layout', () => {
|
|
96
|
+
it('renders in horizontal layout', () => {
|
|
97
|
+
const { lastFrame } = render(_jsx(MobileNav, { items: sampleItems, activeKey: "projects", onNavigate: () => { } }));
|
|
98
|
+
// All items should be on roughly same line (horizontal)
|
|
99
|
+
const output = lastFrame() || '';
|
|
100
|
+
// Check that items appear to be side by side
|
|
101
|
+
expect(output).toContain('Projects');
|
|
102
|
+
expect(output).toContain('Tasks');
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
describe('Keyboard Navigation', () => {
|
|
106
|
+
it('accepts isTTY prop for keyboard support', () => {
|
|
107
|
+
const { lastFrame } = render(_jsx(MobileNav, { items: sampleItems, activeKey: "projects", onNavigate: () => { }, isTTY: true }));
|
|
108
|
+
expect(lastFrame()).toBeDefined();
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import React, { Suspense, lazy } from 'react';
|
|
3
|
+
import { Text, Box } from 'ink';
|
|
4
|
+
import { describe, it, expect } from 'vitest';
|
|
5
|
+
import { render } from 'ink-testing-library';
|
|
6
|
+
describe('Performance', () => {
|
|
7
|
+
describe('Code Splitting', () => {
|
|
8
|
+
it('App renders without full component tree', () => {
|
|
9
|
+
// In terminal UI, we test that minimal components render quickly
|
|
10
|
+
const { lastFrame } = render(_jsx(Text, { children: "App Shell" }));
|
|
11
|
+
expect(lastFrame()).toContain('App Shell');
|
|
12
|
+
});
|
|
13
|
+
it('Skeleton placeholder renders immediately', () => {
|
|
14
|
+
// Skeleton should render fast as loading state
|
|
15
|
+
const { lastFrame } = render(_jsx(Box, { children: _jsx(Text, { color: "gray", children: "\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591" }) }));
|
|
16
|
+
expect(lastFrame()).toContain('░');
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
describe('Lazy Loading', () => {
|
|
20
|
+
it('Suspense fallback renders while loading', () => {
|
|
21
|
+
const LazyComponent = lazy(() => Promise.resolve({
|
|
22
|
+
default: () => _jsx(Text, { children: "Loaded" }),
|
|
23
|
+
}));
|
|
24
|
+
const { lastFrame } = render(_jsx(Suspense, { fallback: _jsx(Text, { children: "Loading..." }), children: _jsx(LazyComponent, {}) }));
|
|
25
|
+
// Should show either loading or loaded
|
|
26
|
+
const output = lastFrame() || '';
|
|
27
|
+
expect(output.length).toBeGreaterThan(0);
|
|
28
|
+
});
|
|
29
|
+
it('Non-critical content can be deferred', () => {
|
|
30
|
+
// Pattern: render critical content first
|
|
31
|
+
const CriticalContent = () => _jsx(Text, { children: "Critical: Dashboard" });
|
|
32
|
+
const DeferredContent = () => _jsx(Text, { children: "Deferred: Analytics" });
|
|
33
|
+
const { lastFrame } = render(_jsxs(Box, { flexDirection: "column", children: [_jsx(CriticalContent, {}), _jsx(Suspense, { fallback: _jsx(Text, { children: "Loading analytics..." }), children: _jsx(DeferredContent, {}) })] }));
|
|
34
|
+
expect(lastFrame()).toContain('Critical: Dashboard');
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
describe('Bundle Size Indicators', () => {
|
|
38
|
+
it('Components use minimal dependencies', () => {
|
|
39
|
+
// Verify components render with basic ink primitives
|
|
40
|
+
const { lastFrame } = render(_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Text, { bold: true, children: "Header" }), _jsx(Text, { children: "Content" }), _jsx(Text, { dimColor: true, children: "Footer" })] }));
|
|
41
|
+
expect(lastFrame()).toContain('Header');
|
|
42
|
+
expect(lastFrame()).toContain('Content');
|
|
43
|
+
expect(lastFrame()).toContain('Footer');
|
|
44
|
+
});
|
|
45
|
+
it('No heavy external libraries required for basic render', () => {
|
|
46
|
+
// Basic components should render with just ink
|
|
47
|
+
const { lastFrame } = render(_jsx(Box, { borderStyle: "single", padding: 1, children: _jsx(Text, { children: "Bordered box" }) }));
|
|
48
|
+
expect(lastFrame()).toContain('Bordered box');
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
describe('Render Performance', () => {
|
|
52
|
+
it('Simple component renders quickly', () => {
|
|
53
|
+
const start = performance.now();
|
|
54
|
+
const { lastFrame } = render(_jsx(Text, { children: "Simple text" }));
|
|
55
|
+
const duration = performance.now() - start;
|
|
56
|
+
expect(lastFrame()).toContain('Simple text');
|
|
57
|
+
// Should render in reasonable time (< 100ms for simple component)
|
|
58
|
+
expect(duration).toBeLessThan(100);
|
|
59
|
+
});
|
|
60
|
+
it('List renders without blocking', () => {
|
|
61
|
+
const items = Array.from({ length: 100 }, (_, i) => `Item ${i}`);
|
|
62
|
+
const start = performance.now();
|
|
63
|
+
const { lastFrame } = render(_jsx(Box, { flexDirection: "column", children: items.slice(0, 10).map((item, i) => (_jsx(Text, { children: item }, i))) }));
|
|
64
|
+
const duration = performance.now() - start;
|
|
65
|
+
expect(lastFrame()).toContain('Item 0');
|
|
66
|
+
// Even with 10 items, should be fast
|
|
67
|
+
expect(duration).toBeLessThan(200);
|
|
68
|
+
});
|
|
69
|
+
it('Memoized components do not re-render unnecessarily', () => {
|
|
70
|
+
const renderCount = { count: 0 };
|
|
71
|
+
const MemoizedComponent = React.memo(function MemoizedComponent({ text }) {
|
|
72
|
+
renderCount.count++;
|
|
73
|
+
return _jsx(Text, { children: text });
|
|
74
|
+
});
|
|
75
|
+
const { rerender, lastFrame } = render(_jsx(MemoizedComponent, { text: "Hello" }));
|
|
76
|
+
expect(renderCount.count).toBe(1);
|
|
77
|
+
// Re-render with same props
|
|
78
|
+
rerender(_jsx(MemoizedComponent, { text: "Hello" }));
|
|
79
|
+
// Should not increase render count significantly
|
|
80
|
+
expect(renderCount.count).toBeLessThanOrEqual(2);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
describe('Memory Efficiency', () => {
|
|
84
|
+
it('Components render and can be unmounted', () => {
|
|
85
|
+
// In ink-testing-library, unmount doesn't trigger useEffect cleanup
|
|
86
|
+
// the same way as react-dom. We verify the component lifecycle works.
|
|
87
|
+
const CleanupComponent = () => {
|
|
88
|
+
return _jsx(Text, { children: "Cleanup test" });
|
|
89
|
+
};
|
|
90
|
+
const { unmount, lastFrame } = render(_jsx(CleanupComponent, {}));
|
|
91
|
+
expect(lastFrame()).toContain('Cleanup test');
|
|
92
|
+
// Verify unmount completes without error
|
|
93
|
+
expect(() => unmount()).not.toThrow();
|
|
94
|
+
});
|
|
95
|
+
it('Event handlers are properly attached', () => {
|
|
96
|
+
// For terminal apps, we use useInput hook
|
|
97
|
+
// This test ensures the pattern is followed
|
|
98
|
+
const { lastFrame } = render(_jsxs(Box, { children: [_jsx(Text, { children: "Press any key" }), _jsx(Text, { dimColor: true, children: "(keyboard input enabled)" })] }));
|
|
99
|
+
expect(lastFrame()).toContain('Press any key');
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
describe('No Render-Blocking', () => {
|
|
103
|
+
it('Progressive content display', () => {
|
|
104
|
+
// Content should render top-to-bottom
|
|
105
|
+
const { lastFrame } = render(_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "Line 1" }), _jsx(Text, { children: "Line 2" }), _jsx(Text, { children: "Line 3" })] }));
|
|
106
|
+
const output = lastFrame() || '';
|
|
107
|
+
const line1Idx = output.indexOf('Line 1');
|
|
108
|
+
const line2Idx = output.indexOf('Line 2');
|
|
109
|
+
const line3Idx = output.indexOf('Line 3');
|
|
110
|
+
expect(line1Idx).toBeLessThan(line2Idx);
|
|
111
|
+
expect(line2Idx).toBeLessThan(line3Idx);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Text, Box } from 'ink';
|
|
3
|
+
import { describe, it, expect } from 'vitest';
|
|
4
|
+
import { render } from 'ink-testing-library';
|
|
5
|
+
// Import layout components
|
|
6
|
+
import { Shell } from './layout/Shell.js';
|
|
7
|
+
import { Sidebar } from './layout/Sidebar.js';
|
|
8
|
+
import { MobileNav } from './layout/MobileNav.js';
|
|
9
|
+
const sampleNavItems = [
|
|
10
|
+
{ key: 'projects', label: 'Projects', icon: '📁' },
|
|
11
|
+
{ key: 'tasks', label: 'Tasks', icon: '📋' },
|
|
12
|
+
{ key: 'logs', label: 'Logs', icon: '📜' },
|
|
13
|
+
];
|
|
14
|
+
describe('Responsive Layout', () => {
|
|
15
|
+
describe('Shell Layout', () => {
|
|
16
|
+
it('renders with sidebar', () => {
|
|
17
|
+
const { lastFrame } = render(_jsx(Shell, { header: _jsx(Text, { children: "Header" }), footer: _jsx(Text, { children: "Footer" }), sidebar: _jsx(Text, { children: "Sidebar" }), showSidebar: true, children: _jsx(Text, { children: "Content" }) }));
|
|
18
|
+
expect(lastFrame()).toContain('Sidebar');
|
|
19
|
+
expect(lastFrame()).toContain('Content');
|
|
20
|
+
});
|
|
21
|
+
it('renders without sidebar when hidden', () => {
|
|
22
|
+
const { lastFrame } = render(_jsx(Shell, { header: _jsx(Text, { children: "Header" }), footer: _jsx(Text, { children: "Footer" }), sidebar: _jsx(Text, { children: "Sidebar" }), showSidebar: false, children: _jsx(Text, { children: "Content" }) }));
|
|
23
|
+
expect(lastFrame()).toContain('Content');
|
|
24
|
+
// Content should still be present when sidebar hidden
|
|
25
|
+
});
|
|
26
|
+
it('renders header and footer', () => {
|
|
27
|
+
const { lastFrame } = render(_jsx(Shell, { header: _jsx(Text, { children: "Header Content" }), footer: _jsx(Text, { children: "Footer Content" }), children: _jsx(Text, { children: "Main" }) }));
|
|
28
|
+
expect(lastFrame()).toContain('Header Content');
|
|
29
|
+
expect(lastFrame()).toContain('Footer Content');
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
describe('Sidebar Component', () => {
|
|
33
|
+
it('renders navigation items', () => {
|
|
34
|
+
const { lastFrame } = render(_jsxs(Sidebar, { title: "TLC", children: [_jsx(Text, { children: "Item 1" }), _jsx(Text, { children: "Item 2" })] }));
|
|
35
|
+
expect(lastFrame()).toContain('TLC');
|
|
36
|
+
expect(lastFrame()).toContain('Item 1');
|
|
37
|
+
expect(lastFrame()).toContain('Item 2');
|
|
38
|
+
});
|
|
39
|
+
it('shows title', () => {
|
|
40
|
+
const { lastFrame } = render(_jsx(Sidebar, { title: "Dashboard", children: _jsx(Text, { children: "Content" }) }));
|
|
41
|
+
expect(lastFrame()).toContain('Dashboard');
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
describe('MobileNav for Phone Layout', () => {
|
|
45
|
+
it('renders all navigation items', () => {
|
|
46
|
+
const { lastFrame } = render(_jsx(MobileNav, { items: sampleNavItems, activeKey: "projects", onNavigate: () => { } }));
|
|
47
|
+
expect(lastFrame()).toContain('Projects');
|
|
48
|
+
expect(lastFrame()).toContain('Tasks');
|
|
49
|
+
expect(lastFrame()).toContain('Logs');
|
|
50
|
+
});
|
|
51
|
+
it('shows active indicator', () => {
|
|
52
|
+
const { lastFrame } = render(_jsx(MobileNav, { items: sampleNavItems, activeKey: "tasks", onNavigate: () => { } }));
|
|
53
|
+
// Active item should be highlighted
|
|
54
|
+
expect(lastFrame()).toContain('Tasks');
|
|
55
|
+
});
|
|
56
|
+
it('supports compact mode for small screens', () => {
|
|
57
|
+
const { lastFrame } = render(_jsx(MobileNav, { items: sampleNavItems, activeKey: "projects", onNavigate: () => { }, compact: true }));
|
|
58
|
+
// Icons should still show in compact mode
|
|
59
|
+
expect(lastFrame()).toContain('📁');
|
|
60
|
+
});
|
|
61
|
+
it('limits visible items for very small screens', () => {
|
|
62
|
+
const manyItems = Array.from({ length: 10 }, (_, i) => ({
|
|
63
|
+
key: `item${i}`,
|
|
64
|
+
label: `Item ${i}`,
|
|
65
|
+
icon: '●',
|
|
66
|
+
}));
|
|
67
|
+
const { lastFrame } = render(_jsx(MobileNav, { items: manyItems, activeKey: "item0", onNavigate: () => { }, maxItems: 4 }));
|
|
68
|
+
// Should show overflow indicator
|
|
69
|
+
expect(lastFrame()).toMatch(/more|⋯/i);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
describe('Touch Targets', () => {
|
|
73
|
+
it('Button has sufficient padding for touch', () => {
|
|
74
|
+
// In terminal UI, we ensure buttons have visible brackets
|
|
75
|
+
const { lastFrame } = render(_jsx(Box, { children: _jsx(Text, { children: "[ OK ]" }) }));
|
|
76
|
+
expect(lastFrame()).toContain('[');
|
|
77
|
+
expect(lastFrame()).toContain(']');
|
|
78
|
+
});
|
|
79
|
+
it('Navigation items are spaced apart', () => {
|
|
80
|
+
const { lastFrame } = render(_jsx(MobileNav, { items: sampleNavItems, activeKey: "projects", onNavigate: () => { } }));
|
|
81
|
+
// Items should all be visible (spaced)
|
|
82
|
+
expect(lastFrame()).toContain('Projects');
|
|
83
|
+
expect(lastFrame()).toContain('Tasks');
|
|
84
|
+
expect(lastFrame()).toContain('Logs');
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
describe('Content Fitting', () => {
|
|
88
|
+
it('Content renders without truncation', () => {
|
|
89
|
+
const { lastFrame } = render(_jsx(Box, { width: 80, children: _jsx(Text, { children: "This is a reasonably long text that should fit within the viewport" }) }));
|
|
90
|
+
expect(lastFrame()).toContain('This is a reasonably long text');
|
|
91
|
+
});
|
|
92
|
+
it('Long labels truncate gracefully', () => {
|
|
93
|
+
const { lastFrame } = render(_jsx(MobileNav, { items: [
|
|
94
|
+
{ key: 'long', label: 'Very Long Navigation Item Label', icon: '📁' },
|
|
95
|
+
], activeKey: "long", onNavigate: () => { } }));
|
|
96
|
+
// Should render something (truncated or full)
|
|
97
|
+
expect(lastFrame()).toBeDefined();
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
describe('Readable Font Sizes', () => {
|
|
101
|
+
it('Primary text renders clearly', () => {
|
|
102
|
+
const { lastFrame } = render(_jsx(Text, { children: "Primary readable text" }));
|
|
103
|
+
expect(lastFrame()).toContain('Primary readable text');
|
|
104
|
+
});
|
|
105
|
+
it('Secondary text renders with dimColor', () => {
|
|
106
|
+
const { lastFrame } = render(_jsx(Text, { dimColor: true, children: "Secondary text" }));
|
|
107
|
+
expect(lastFrame()).toContain('Secondary text');
|
|
108
|
+
});
|
|
109
|
+
it('Bold text renders for emphasis', () => {
|
|
110
|
+
const { lastFrame } = render(_jsx(Text, { bold: true, children: "Important text" }));
|
|
111
|
+
expect(lastFrame()).toContain('Important text');
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface DropdownOption {
|
|
2
|
+
value: string;
|
|
3
|
+
label: string;
|
|
4
|
+
disabled?: boolean;
|
|
5
|
+
group?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface DropdownProps {
|
|
8
|
+
options: DropdownOption[];
|
|
9
|
+
onSelect: (value: string | string[]) => void;
|
|
10
|
+
value?: string | string[];
|
|
11
|
+
placeholder?: string;
|
|
12
|
+
isOpen?: boolean;
|
|
13
|
+
onOpenChange?: (open: boolean) => void;
|
|
14
|
+
multiple?: boolean;
|
|
15
|
+
filterable?: boolean;
|
|
16
|
+
filterQuery?: string;
|
|
17
|
+
onFilterChange?: (query: string) => void;
|
|
18
|
+
maxHeight?: number;
|
|
19
|
+
isTTY?: boolean;
|
|
20
|
+
}
|
|
21
|
+
export declare function Dropdown({ options, onSelect, value, placeholder, isOpen, onOpenChange, multiple, filterable, filterQuery, onFilterChange, maxHeight, isTTY, }: DropdownProps): import("react/jsx-runtime").JSX.Element;
|
|
22
|
+
export default Dropdown;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text, useInput } from 'ink';
|
|
3
|
+
import { useState, useMemo } from 'react';
|
|
4
|
+
export function Dropdown({ options, onSelect, value, placeholder = 'Select...', isOpen = false, onOpenChange, multiple = false, filterable = false, filterQuery = '', onFilterChange, maxHeight = 10, isTTY = true, }) {
|
|
5
|
+
const [highlightIndex, setHighlightIndex] = useState(0);
|
|
6
|
+
// Filter options based on query
|
|
7
|
+
const filteredOptions = useMemo(() => {
|
|
8
|
+
if (!filterQuery)
|
|
9
|
+
return options;
|
|
10
|
+
const query = filterQuery.toLowerCase();
|
|
11
|
+
return options.filter(opt => opt.label.toLowerCase().includes(query) ||
|
|
12
|
+
opt.value.toLowerCase().includes(query));
|
|
13
|
+
}, [options, filterQuery]);
|
|
14
|
+
// Group options
|
|
15
|
+
const groupedOptions = useMemo(() => {
|
|
16
|
+
const groups = {};
|
|
17
|
+
filteredOptions.forEach(opt => {
|
|
18
|
+
const group = opt.group || '__default__';
|
|
19
|
+
if (!groups[group])
|
|
20
|
+
groups[group] = [];
|
|
21
|
+
groups[group].push(opt);
|
|
22
|
+
});
|
|
23
|
+
return groups;
|
|
24
|
+
}, [filteredOptions]);
|
|
25
|
+
// Get display value
|
|
26
|
+
const displayValue = useMemo(() => {
|
|
27
|
+
if (!value)
|
|
28
|
+
return placeholder;
|
|
29
|
+
if (Array.isArray(value)) {
|
|
30
|
+
if (value.length === 0)
|
|
31
|
+
return placeholder;
|
|
32
|
+
const labels = value.map(v => {
|
|
33
|
+
const opt = options.find(o => o.value === v);
|
|
34
|
+
return opt?.label || v;
|
|
35
|
+
});
|
|
36
|
+
return labels.join(', ');
|
|
37
|
+
}
|
|
38
|
+
const opt = options.find(o => o.value === value);
|
|
39
|
+
return opt?.label || placeholder;
|
|
40
|
+
}, [value, options, placeholder]);
|
|
41
|
+
// Check if option is selected
|
|
42
|
+
const isSelected = (optValue) => {
|
|
43
|
+
if (Array.isArray(value))
|
|
44
|
+
return value.includes(optValue);
|
|
45
|
+
return value === optValue;
|
|
46
|
+
};
|
|
47
|
+
// Handle keyboard input
|
|
48
|
+
useInput((input, key) => {
|
|
49
|
+
if (!isOpen || !isTTY)
|
|
50
|
+
return;
|
|
51
|
+
if (key.escape) {
|
|
52
|
+
onOpenChange?.(false);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (key.downArrow || input === 'j') {
|
|
56
|
+
setHighlightIndex(prev => {
|
|
57
|
+
let next = prev + 1;
|
|
58
|
+
while (next < filteredOptions.length && filteredOptions[next]?.disabled) {
|
|
59
|
+
next++;
|
|
60
|
+
}
|
|
61
|
+
return next < filteredOptions.length ? next : prev;
|
|
62
|
+
});
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (key.upArrow || input === 'k') {
|
|
66
|
+
setHighlightIndex(prev => {
|
|
67
|
+
let next = prev - 1;
|
|
68
|
+
while (next >= 0 && filteredOptions[next]?.disabled) {
|
|
69
|
+
next--;
|
|
70
|
+
}
|
|
71
|
+
return next >= 0 ? next : prev;
|
|
72
|
+
});
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (key.return) {
|
|
76
|
+
const selected = filteredOptions[highlightIndex];
|
|
77
|
+
if (selected && !selected.disabled) {
|
|
78
|
+
if (multiple) {
|
|
79
|
+
const currentValues = Array.isArray(value) ? value : [];
|
|
80
|
+
const newValues = isSelected(selected.value)
|
|
81
|
+
? currentValues.filter(v => v !== selected.value)
|
|
82
|
+
: [...currentValues, selected.value];
|
|
83
|
+
onSelect(newValues);
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
onSelect(selected.value);
|
|
87
|
+
onOpenChange?.(false);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
// Type-ahead filtering
|
|
93
|
+
if (filterable && input && input.length === 1 && /[a-zA-Z0-9]/.test(input)) {
|
|
94
|
+
onFilterChange?.(filterQuery + input);
|
|
95
|
+
}
|
|
96
|
+
}, { isActive: isTTY && isOpen });
|
|
97
|
+
if (!isOpen) {
|
|
98
|
+
// Closed state - show trigger
|
|
99
|
+
return (_jsxs(Box, { children: [_jsx(Text, { children: displayValue }), _jsx(Text, { color: "gray", children: " \u25BE" })] }));
|
|
100
|
+
}
|
|
101
|
+
// Open state - show dropdown list
|
|
102
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: displayValue }), _jsx(Text, { color: "cyan", children: " \u25B4" })] }), filterable && (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "gray", children: "> " }), _jsx(Text, { children: filterQuery }), _jsx(Text, { color: "cyan", children: "\u258F" })] })), _jsx(Box, { flexDirection: "column", marginTop: 1, borderStyle: "single", borderColor: "gray", paddingX: 1, children: filteredOptions.length === 0 ? (_jsx(Text, { color: "gray", children: "No matching options" })) : (Object.entries(groupedOptions).map(([group, opts]) => (_jsxs(Box, { flexDirection: "column", children: [group !== '__default__' && (_jsx(Text, { bold: true, color: "cyan", children: group })), opts.slice(0, maxHeight).map((opt, idx) => {
|
|
103
|
+
const globalIdx = filteredOptions.indexOf(opt);
|
|
104
|
+
const isHighlighted = globalIdx === highlightIndex;
|
|
105
|
+
const isChecked = isSelected(opt.value);
|
|
106
|
+
return (_jsxs(Box, { children: [_jsx(Text, { color: isHighlighted ? 'cyan' : undefined, children: isHighlighted ? '▶ ' : ' ' }), multiple && (_jsx(Text, { color: isChecked ? 'green' : 'gray', children: isChecked ? '☑ ' : '☐ ' })), _jsx(Text, { color: opt.disabled ? 'gray' : undefined, dimColor: opt.disabled, children: opt.label })] }, opt.value));
|
|
107
|
+
})] }, group)))) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "gray", children: "\u2191\u2193 navigate | Enter select | Esc close" }) })] }));
|
|
108
|
+
}
|
|
109
|
+
export default Dropdown;
|