@hauktui/registry 0.0.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/components/accordion/accordion.tsx +146 -0
- package/components/accordion/index.ts +2 -0
- package/components/alert/alert.tsx +69 -0
- package/components/alert/index.ts +2 -0
- package/components/alert-dialog/alert-dialog.tsx +185 -0
- package/components/alert-dialog/index.ts +2 -0
- package/components/avatar/avatar.tsx +57 -0
- package/components/avatar/index.ts +2 -0
- package/components/avatar-group/avatar-group.tsx +144 -0
- package/components/avatar-group/index.ts +2 -0
- package/components/badge/badge.tsx +52 -0
- package/components/badge/index.ts +2 -0
- package/components/banner/banner.tsx +407 -0
- package/components/banner/index.ts +2 -0
- package/components/breadcrumb/breadcrumb.tsx +58 -0
- package/components/breadcrumb/index.ts +2 -0
- package/components/button/button.tsx +114 -0
- package/components/button/index.ts +2 -0
- package/components/calendar/calendar.tsx +250 -0
- package/components/calendar/index.ts +2 -0
- package/components/card/card.tsx +88 -0
- package/components/card/index.ts +2 -0
- package/components/carousel/carousel.tsx +185 -0
- package/components/carousel/index.ts +2 -0
- package/components/chart/chart.tsx +189 -0
- package/components/chart/index.ts +2 -0
- package/components/checkbox/checkbox.tsx +98 -0
- package/components/checkbox/index.ts +2 -0
- package/components/code-block/code-block.tsx +214 -0
- package/components/code-block/index.ts +2 -0
- package/components/collapsible/collapsible.tsx +123 -0
- package/components/collapsible/index.ts +2 -0
- package/components/color-picker/color-picker.tsx +211 -0
- package/components/color-picker/index.ts +2 -0
- package/components/combobox/combobox.tsx +275 -0
- package/components/combobox/index.ts +2 -0
- package/components/command/command.tsx +304 -0
- package/components/command/index.ts +2 -0
- package/components/confirm-dialog/confirm-dialog.tsx +140 -0
- package/components/confirm-dialog/index.ts +2 -0
- package/components/context-menu/context-menu.tsx +188 -0
- package/components/context-menu/index.ts +2 -0
- package/components/countdown/countdown.tsx +165 -0
- package/components/countdown/index.ts +2 -0
- package/components/data-table/data-table.tsx +256 -0
- package/components/data-table/index.ts +2 -0
- package/components/date-picker/date-picker.tsx +280 -0
- package/components/date-picker/index.ts +2 -0
- package/components/dialog/dialog.tsx +84 -0
- package/components/dialog/index.ts +2 -0
- package/components/drawer/drawer.tsx +141 -0
- package/components/drawer/index.ts +2 -0
- package/components/dropdown-menu/dropdown-menu.tsx +188 -0
- package/components/dropdown-menu/index.ts +2 -0
- package/components/empty/empty.tsx +107 -0
- package/components/empty/index.ts +2 -0
- package/components/field/field.tsx +83 -0
- package/components/field/index.ts +2 -0
- package/components/form/form.tsx +202 -0
- package/components/form/index.ts +8 -0
- package/components/hover-card/hover-card.tsx +72 -0
- package/components/hover-card/index.ts +2 -0
- package/components/input-otp/index.ts +2 -0
- package/components/input-otp/input-otp.tsx +176 -0
- package/components/kbd/index.ts +2 -0
- package/components/kbd/kbd.tsx +30 -0
- package/components/label/index.ts +2 -0
- package/components/label/label.tsx +56 -0
- package/components/list/index.ts +2 -0
- package/components/list/list.tsx +247 -0
- package/components/menubar/index.ts +2 -0
- package/components/menubar/menubar.tsx +220 -0
- package/components/navigation-menu/index.ts +6 -0
- package/components/navigation-menu/navigation-menu.tsx +216 -0
- package/components/pagination/index.ts +2 -0
- package/components/pagination/pagination.tsx +158 -0
- package/components/password-input/index.ts +2 -0
- package/components/password-input/password-input.tsx +198 -0
- package/components/popover/index.ts +2 -0
- package/components/popover/popover.tsx +102 -0
- package/components/progress/index.ts +2 -0
- package/components/progress/progress.tsx +73 -0
- package/components/radio-group/index.ts +2 -0
- package/components/radio-group/radio-group.tsx +167 -0
- package/components/resizable/index.ts +2 -0
- package/components/resizable/resizable.tsx +141 -0
- package/components/scroll-area/index.ts +2 -0
- package/components/scroll-area/scroll-area.tsx +133 -0
- package/components/select/index.ts +2 -0
- package/components/select/select.tsx +185 -0
- package/components/separator/index.ts +2 -0
- package/components/separator/separator.tsx +63 -0
- package/components/sheet/index.ts +2 -0
- package/components/sheet/sheet.tsx +137 -0
- package/components/sidebar/index.ts +2 -0
- package/components/sidebar/sidebar.tsx +225 -0
- package/components/skeleton/index.ts +2 -0
- package/components/skeleton/skeleton.tsx +64 -0
- package/components/slider/index.ts +2 -0
- package/components/slider/slider.tsx +128 -0
- package/components/spinner/index.ts +2 -0
- package/components/spinner/spinner.tsx +57 -0
- package/components/stat/index.ts +2 -0
- package/components/stat/stat.tsx +138 -0
- package/components/stepper/index.ts +2 -0
- package/components/stepper/stepper.tsx +219 -0
- package/components/switch/index.ts +2 -0
- package/components/switch/switch.tsx +102 -0
- package/components/table/index.ts +2 -0
- package/components/table/table.tsx +242 -0
- package/components/tabs/index.ts +2 -0
- package/components/tabs/tabs.tsx +240 -0
- package/components/tag-input/index.ts +2 -0
- package/components/tag-input/tag-input.tsx +180 -0
- package/components/terminal/index.ts +2 -0
- package/components/terminal/terminal.tsx +162 -0
- package/components/text-input/index.ts +2 -0
- package/components/text-input/text-input.tsx +179 -0
- package/components/textarea/index.ts +2 -0
- package/components/textarea/textarea.tsx +206 -0
- package/components/timeline/index.ts +2 -0
- package/components/timeline/timeline.tsx +167 -0
- package/components/toast/index.ts +2 -0
- package/components/toast/toast.tsx +93 -0
- package/components/toggle/index.ts +2 -0
- package/components/toggle/toggle.tsx +114 -0
- package/components/toggle-group/index.ts +2 -0
- package/components/toggle-group/toggle-group.tsx +176 -0
- package/components/tooltip/index.ts +2 -0
- package/components/tooltip/tooltip.tsx +65 -0
- package/components/tree-view/index.ts +2 -0
- package/components/tree-view/tree-view.tsx +245 -0
- package/components/typography/index.ts +12 -0
- package/components/typography/typography.tsx +154 -0
- package/dist/index.d.ts +102 -0
- package/dist/index.js +938 -0
- package/dist/index.js.map +1 -0
- package/package.json +41 -0
- package/registry.json +923 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import React, { useState, useCallback, useEffect, useMemo } from "react";
|
|
2
|
+
import { Box, Text, useInput } from "ink";
|
|
3
|
+
import type { Tokens } from "@hauktui/tokens";
|
|
4
|
+
import { useTokens, useFocusable } from "@hauktui/primitives-ink";
|
|
5
|
+
import { stableId, clamp } from "@hauktui/core";
|
|
6
|
+
|
|
7
|
+
export interface ListItem<T = string> {
|
|
8
|
+
/** Display label */
|
|
9
|
+
label: string;
|
|
10
|
+
/** Item value */
|
|
11
|
+
value: T;
|
|
12
|
+
/** Secondary text/description */
|
|
13
|
+
description?: string;
|
|
14
|
+
/** Whether this item is disabled */
|
|
15
|
+
disabled?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ListProps<T = string> {
|
|
19
|
+
/** List items */
|
|
20
|
+
items: ListItem<T>[];
|
|
21
|
+
/** Controlled selected value */
|
|
22
|
+
value?: T;
|
|
23
|
+
/** Default value for uncontrolled mode */
|
|
24
|
+
defaultValue?: T;
|
|
25
|
+
/** Callback when selection changes */
|
|
26
|
+
onChange?: (value: T) => void;
|
|
27
|
+
/** Callback when Enter is pressed on selected item */
|
|
28
|
+
onSelect?: (value: T) => void;
|
|
29
|
+
/** Whether the list is disabled */
|
|
30
|
+
disabled?: boolean;
|
|
31
|
+
/** Custom tokens override */
|
|
32
|
+
tokens?: Tokens;
|
|
33
|
+
/** Focus ID for focus management */
|
|
34
|
+
focusId?: string;
|
|
35
|
+
/** Maximum visible items before scrolling */
|
|
36
|
+
maxHeight?: number;
|
|
37
|
+
/** Show item descriptions */
|
|
38
|
+
showDescriptions?: boolean;
|
|
39
|
+
/** Show scroll indicators */
|
|
40
|
+
showScrollIndicators?: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function List<T = string>({
|
|
44
|
+
items,
|
|
45
|
+
value: controlledValue,
|
|
46
|
+
defaultValue,
|
|
47
|
+
onChange,
|
|
48
|
+
onSelect,
|
|
49
|
+
disabled = false,
|
|
50
|
+
tokens: propTokens,
|
|
51
|
+
focusId,
|
|
52
|
+
maxHeight = 10,
|
|
53
|
+
showDescriptions = false,
|
|
54
|
+
showScrollIndicators = true,
|
|
55
|
+
}: ListProps<T>): React.ReactElement {
|
|
56
|
+
const contextTokens = useTokens();
|
|
57
|
+
const tokens = propTokens ?? contextTokens;
|
|
58
|
+
const id = focusId ?? stableId("list");
|
|
59
|
+
const { isFocused } = useFocusable(id);
|
|
60
|
+
|
|
61
|
+
// Internal state for uncontrolled mode
|
|
62
|
+
const [internalValue, setInternalValue] = useState<T | undefined>(defaultValue);
|
|
63
|
+
const isControlled = controlledValue !== undefined;
|
|
64
|
+
const currentValue = isControlled ? controlledValue : internalValue;
|
|
65
|
+
|
|
66
|
+
// Highlighted index for keyboard navigation
|
|
67
|
+
const [highlightedIndex, setHighlightedIndex] = useState(() => {
|
|
68
|
+
if (currentValue !== undefined) {
|
|
69
|
+
const idx = items.findIndex((item) => item.value === currentValue);
|
|
70
|
+
return idx >= 0 ? idx : 0;
|
|
71
|
+
}
|
|
72
|
+
return 0;
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// Scroll offset
|
|
76
|
+
const [scrollOffset, setScrollOffset] = useState(0);
|
|
77
|
+
|
|
78
|
+
// Update highlighted index when value changes externally
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (currentValue !== undefined) {
|
|
81
|
+
const idx = items.findIndex((item) => item.value === currentValue);
|
|
82
|
+
if (idx >= 0) {
|
|
83
|
+
setHighlightedIndex(idx);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}, [currentValue, items]);
|
|
87
|
+
|
|
88
|
+
// Ensure scroll keeps highlighted item visible
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
if (highlightedIndex < scrollOffset) {
|
|
91
|
+
setScrollOffset(highlightedIndex);
|
|
92
|
+
} else if (highlightedIndex >= scrollOffset + maxHeight) {
|
|
93
|
+
setScrollOffset(highlightedIndex - maxHeight + 1);
|
|
94
|
+
}
|
|
95
|
+
}, [highlightedIndex, scrollOffset, maxHeight]);
|
|
96
|
+
|
|
97
|
+
const selectItem = useCallback(
|
|
98
|
+
(index: number) => {
|
|
99
|
+
const item = items[index];
|
|
100
|
+
if (!item || item.disabled) return;
|
|
101
|
+
|
|
102
|
+
if (!isControlled) {
|
|
103
|
+
setInternalValue(item.value);
|
|
104
|
+
}
|
|
105
|
+
onChange?.(item.value);
|
|
106
|
+
},
|
|
107
|
+
[items, isControlled, onChange]
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
// Handle keyboard navigation
|
|
111
|
+
useInput(
|
|
112
|
+
(input, key) => {
|
|
113
|
+
if (!isFocused || disabled) return;
|
|
114
|
+
|
|
115
|
+
if (key.upArrow || input === "k") {
|
|
116
|
+
setHighlightedIndex((prev) => {
|
|
117
|
+
let next = prev - 1;
|
|
118
|
+
// Skip disabled items
|
|
119
|
+
while (next >= 0 && items[next]?.disabled) {
|
|
120
|
+
next--;
|
|
121
|
+
}
|
|
122
|
+
return clamp(next, 0, items.length - 1);
|
|
123
|
+
});
|
|
124
|
+
} else if (key.downArrow || input === "j") {
|
|
125
|
+
setHighlightedIndex((prev) => {
|
|
126
|
+
let next = prev + 1;
|
|
127
|
+
// Skip disabled items
|
|
128
|
+
while (next < items.length && items[next]?.disabled) {
|
|
129
|
+
next++;
|
|
130
|
+
}
|
|
131
|
+
return clamp(next, 0, items.length - 1);
|
|
132
|
+
});
|
|
133
|
+
} else if (key.return || input === " ") {
|
|
134
|
+
selectItem(highlightedIndex);
|
|
135
|
+
const item = items[highlightedIndex];
|
|
136
|
+
if (item && !item.disabled) {
|
|
137
|
+
onSelect?.(item.value);
|
|
138
|
+
}
|
|
139
|
+
} else if (input === "g") {
|
|
140
|
+
// Go to top
|
|
141
|
+
setHighlightedIndex(0);
|
|
142
|
+
setScrollOffset(0);
|
|
143
|
+
} else if (input === "G") {
|
|
144
|
+
// Go to bottom
|
|
145
|
+
setHighlightedIndex(items.length - 1);
|
|
146
|
+
} else if (key.pageUp) {
|
|
147
|
+
setHighlightedIndex((prev) => clamp(prev - maxHeight, 0, items.length - 1));
|
|
148
|
+
} else if (key.pageDown) {
|
|
149
|
+
setHighlightedIndex((prev) => clamp(prev + maxHeight, 0, items.length - 1));
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
{ isActive: isFocused }
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
// Calculate visible items
|
|
156
|
+
const visibleItems = useMemo(() => {
|
|
157
|
+
return items.slice(scrollOffset, scrollOffset + maxHeight);
|
|
158
|
+
}, [items, scrollOffset, maxHeight]);
|
|
159
|
+
|
|
160
|
+
// Check if we need scroll indicators
|
|
161
|
+
const canScrollUp = scrollOffset > 0;
|
|
162
|
+
const canScrollDown = scrollOffset + maxHeight < items.length;
|
|
163
|
+
|
|
164
|
+
return React.createElement(
|
|
165
|
+
Box,
|
|
166
|
+
{
|
|
167
|
+
flexDirection: "column",
|
|
168
|
+
borderStyle: isFocused ? "round" : "single",
|
|
169
|
+
borderColor: isFocused ? tokens.colors.focus : tokens.colors.border,
|
|
170
|
+
},
|
|
171
|
+
// Scroll up indicator
|
|
172
|
+
showScrollIndicators && canScrollUp
|
|
173
|
+
? React.createElement(
|
|
174
|
+
Box,
|
|
175
|
+
{ justifyContent: "center" },
|
|
176
|
+
React.createElement(Text, { color: tokens.colors.muted }, "▲ more")
|
|
177
|
+
)
|
|
178
|
+
: null,
|
|
179
|
+
// List items
|
|
180
|
+
visibleItems.map((item, visibleIndex) => {
|
|
181
|
+
const actualIndex = scrollOffset + visibleIndex;
|
|
182
|
+
const isSelected = item.value === currentValue;
|
|
183
|
+
const isHighlighted = actualIndex === highlightedIndex;
|
|
184
|
+
const isDisabled = item.disabled;
|
|
185
|
+
|
|
186
|
+
let color = tokens.colors.fg;
|
|
187
|
+
if (isDisabled) {
|
|
188
|
+
color = tokens.colors.disabled;
|
|
189
|
+
} else if (isHighlighted) {
|
|
190
|
+
color = tokens.colors.accent;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const prefix = isSelected ? "● " : " ";
|
|
194
|
+
const highlight = isHighlighted && isFocused;
|
|
195
|
+
|
|
196
|
+
return React.createElement(
|
|
197
|
+
Box,
|
|
198
|
+
{
|
|
199
|
+
key: actualIndex,
|
|
200
|
+
flexDirection: "column",
|
|
201
|
+
paddingX: 1,
|
|
202
|
+
},
|
|
203
|
+
React.createElement(
|
|
204
|
+
Box,
|
|
205
|
+
null,
|
|
206
|
+
React.createElement(
|
|
207
|
+
Text,
|
|
208
|
+
{
|
|
209
|
+
color,
|
|
210
|
+
bold: highlight,
|
|
211
|
+
dimColor: isDisabled,
|
|
212
|
+
inverse: highlight,
|
|
213
|
+
},
|
|
214
|
+
prefix,
|
|
215
|
+
item.label
|
|
216
|
+
)
|
|
217
|
+
),
|
|
218
|
+
showDescriptions && item.description
|
|
219
|
+
? React.createElement(
|
|
220
|
+
Text,
|
|
221
|
+
{ color: tokens.colors.muted, dimColor: true },
|
|
222
|
+
" ",
|
|
223
|
+
item.description
|
|
224
|
+
)
|
|
225
|
+
: null
|
|
226
|
+
);
|
|
227
|
+
}),
|
|
228
|
+
// Scroll down indicator
|
|
229
|
+
showScrollIndicators && canScrollDown
|
|
230
|
+
? React.createElement(
|
|
231
|
+
Box,
|
|
232
|
+
{ justifyContent: "center" },
|
|
233
|
+
React.createElement(Text, { color: tokens.colors.muted }, "▼ more")
|
|
234
|
+
)
|
|
235
|
+
: null,
|
|
236
|
+
// Item count indicator
|
|
237
|
+
React.createElement(
|
|
238
|
+
Box,
|
|
239
|
+
{ justifyContent: "flex-end", paddingX: 1 },
|
|
240
|
+
React.createElement(
|
|
241
|
+
Text,
|
|
242
|
+
{ color: tokens.colors.muted, dimColor: true },
|
|
243
|
+
`${highlightedIndex + 1}/${items.length}`
|
|
244
|
+
)
|
|
245
|
+
)
|
|
246
|
+
);
|
|
247
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import React, { useState, useCallback } from "react";
|
|
2
|
+
import { Box, Text, useInput } from "ink";
|
|
3
|
+
import type { Tokens } from "@hauktui/tokens";
|
|
4
|
+
import { useTokens, useFocusable } from "@hauktui/primitives-ink";
|
|
5
|
+
import { stableId, clamp } from "@hauktui/core";
|
|
6
|
+
|
|
7
|
+
export interface MenubarItem {
|
|
8
|
+
/** Unique identifier */
|
|
9
|
+
id: string;
|
|
10
|
+
/** Display label */
|
|
11
|
+
label: string;
|
|
12
|
+
/** Optional keyboard shortcut hint */
|
|
13
|
+
shortcut?: string;
|
|
14
|
+
/** Sub-items for dropdown */
|
|
15
|
+
items?: MenubarSubItem[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface MenubarSubItem {
|
|
19
|
+
/** Unique identifier */
|
|
20
|
+
id: string;
|
|
21
|
+
/** Display label */
|
|
22
|
+
label: string;
|
|
23
|
+
/** Optional keyboard shortcut */
|
|
24
|
+
shortcut?: string;
|
|
25
|
+
/** Whether this item is disabled */
|
|
26
|
+
disabled?: boolean;
|
|
27
|
+
/** Whether this is a separator */
|
|
28
|
+
separator?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface MenubarProps {
|
|
32
|
+
/** Menu items */
|
|
33
|
+
items: MenubarItem[];
|
|
34
|
+
/** Callback when an item is selected */
|
|
35
|
+
onSelect: (menuId: string, itemId: string) => void;
|
|
36
|
+
/** Custom tokens override */
|
|
37
|
+
tokens?: Tokens;
|
|
38
|
+
/** Focus ID for focus management */
|
|
39
|
+
focusId?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function Menubar({
|
|
43
|
+
items,
|
|
44
|
+
onSelect,
|
|
45
|
+
tokens: propTokens,
|
|
46
|
+
focusId,
|
|
47
|
+
}: MenubarProps): React.ReactElement {
|
|
48
|
+
const contextTokens = useTokens();
|
|
49
|
+
const tokens = propTokens ?? contextTokens;
|
|
50
|
+
const id = focusId ?? stableId("menubar");
|
|
51
|
+
const { isFocused } = useFocusable(id);
|
|
52
|
+
|
|
53
|
+
const [activeMenu, setActiveMenu] = useState<string | null>(null);
|
|
54
|
+
const [menuIndex, setMenuIndex] = useState(0);
|
|
55
|
+
const [subIndex, setSubIndex] = useState(0);
|
|
56
|
+
|
|
57
|
+
const activeMenuItem = items.find((item) => item.id === activeMenu);
|
|
58
|
+
const selectableSubItems =
|
|
59
|
+
activeMenuItem?.items?.filter(
|
|
60
|
+
(item) => !item.separator && !item.disabled
|
|
61
|
+
) || [];
|
|
62
|
+
|
|
63
|
+
const handleSelect = useCallback(() => {
|
|
64
|
+
if (activeMenu && selectableSubItems[subIndex]) {
|
|
65
|
+
onSelect(activeMenu, selectableSubItems[subIndex]!.id);
|
|
66
|
+
setActiveMenu(null);
|
|
67
|
+
}
|
|
68
|
+
}, [activeMenu, selectableSubItems, subIndex, onSelect]);
|
|
69
|
+
|
|
70
|
+
useInput(
|
|
71
|
+
(input, key) => {
|
|
72
|
+
if (!isFocused) return;
|
|
73
|
+
|
|
74
|
+
if (key.escape) {
|
|
75
|
+
setActiveMenu(null);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!activeMenu) {
|
|
80
|
+
// Top-level navigation
|
|
81
|
+
if (key.leftArrow || input === "h") {
|
|
82
|
+
setMenuIndex((prev) => clamp(prev - 1, 0, items.length - 1));
|
|
83
|
+
} else if (key.rightArrow || input === "l") {
|
|
84
|
+
setMenuIndex((prev) => clamp(prev + 1, 0, items.length - 1));
|
|
85
|
+
} else if (key.return || key.downArrow) {
|
|
86
|
+
setActiveMenu(items[menuIndex]?.id || null);
|
|
87
|
+
setSubIndex(0);
|
|
88
|
+
}
|
|
89
|
+
} else {
|
|
90
|
+
// Submenu navigation
|
|
91
|
+
if (key.upArrow || input === "k") {
|
|
92
|
+
setSubIndex((prev) =>
|
|
93
|
+
clamp(prev - 1, 0, selectableSubItems.length - 1)
|
|
94
|
+
);
|
|
95
|
+
} else if (key.downArrow || input === "j") {
|
|
96
|
+
setSubIndex((prev) =>
|
|
97
|
+
clamp(prev + 1, 0, selectableSubItems.length - 1)
|
|
98
|
+
);
|
|
99
|
+
} else if (key.leftArrow || input === "h") {
|
|
100
|
+
const newIndex = clamp(menuIndex - 1, 0, items.length - 1);
|
|
101
|
+
setMenuIndex(newIndex);
|
|
102
|
+
setActiveMenu(items[newIndex]?.id || null);
|
|
103
|
+
setSubIndex(0);
|
|
104
|
+
} else if (key.rightArrow || input === "l") {
|
|
105
|
+
const newIndex = clamp(menuIndex + 1, 0, items.length - 1);
|
|
106
|
+
setMenuIndex(newIndex);
|
|
107
|
+
setActiveMenu(items[newIndex]?.id || null);
|
|
108
|
+
setSubIndex(0);
|
|
109
|
+
} else if (key.return) {
|
|
110
|
+
handleSelect();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
{ isActive: isFocused }
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
return React.createElement(
|
|
118
|
+
Box,
|
|
119
|
+
{ flexDirection: "column" },
|
|
120
|
+
// Menu bar
|
|
121
|
+
React.createElement(
|
|
122
|
+
Box,
|
|
123
|
+
{
|
|
124
|
+
borderStyle: "single",
|
|
125
|
+
borderColor: isFocused ? tokens.colors.focus : tokens.colors.border,
|
|
126
|
+
borderBottom: true,
|
|
127
|
+
borderTop: false,
|
|
128
|
+
borderLeft: false,
|
|
129
|
+
borderRight: false,
|
|
130
|
+
paddingX: 1,
|
|
131
|
+
gap: 2,
|
|
132
|
+
},
|
|
133
|
+
...items.map((item, index) => {
|
|
134
|
+
const isActive = index === menuIndex;
|
|
135
|
+
const isOpen = activeMenu === item.id;
|
|
136
|
+
|
|
137
|
+
return React.createElement(
|
|
138
|
+
Text,
|
|
139
|
+
{
|
|
140
|
+
key: item.id,
|
|
141
|
+
color: isOpen
|
|
142
|
+
? tokens.colors.accent
|
|
143
|
+
: isActive
|
|
144
|
+
? tokens.colors.fg
|
|
145
|
+
: tokens.colors.muted,
|
|
146
|
+
bold: isOpen || isActive,
|
|
147
|
+
inverse: isOpen,
|
|
148
|
+
},
|
|
149
|
+
` ${item.label} `
|
|
150
|
+
);
|
|
151
|
+
})
|
|
152
|
+
),
|
|
153
|
+
// Dropdown
|
|
154
|
+
activeMenuItem?.items
|
|
155
|
+
? React.createElement(
|
|
156
|
+
Box,
|
|
157
|
+
{
|
|
158
|
+
flexDirection: "column",
|
|
159
|
+
borderStyle: "round",
|
|
160
|
+
borderColor: tokens.colors.border,
|
|
161
|
+
width: 25,
|
|
162
|
+
marginLeft: menuIndex * 10,
|
|
163
|
+
},
|
|
164
|
+
...activeMenuItem.items.map((item, index) => {
|
|
165
|
+
if (item.separator) {
|
|
166
|
+
return React.createElement(
|
|
167
|
+
Box,
|
|
168
|
+
{ key: `sep-${index}`, paddingX: 1 },
|
|
169
|
+
React.createElement(
|
|
170
|
+
Text,
|
|
171
|
+
{ color: tokens.colors.border },
|
|
172
|
+
"─".repeat(21)
|
|
173
|
+
)
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const isSelected = selectableSubItems[subIndex]?.id === item.id;
|
|
178
|
+
|
|
179
|
+
return React.createElement(
|
|
180
|
+
Box,
|
|
181
|
+
{ key: item.id, paddingX: 1, justifyContent: "space-between" },
|
|
182
|
+
React.createElement(
|
|
183
|
+
Box,
|
|
184
|
+
{ gap: 1 },
|
|
185
|
+
React.createElement(
|
|
186
|
+
Text,
|
|
187
|
+
{
|
|
188
|
+
color: isSelected
|
|
189
|
+
? tokens.colors.accent
|
|
190
|
+
: tokens.colors.muted,
|
|
191
|
+
},
|
|
192
|
+
isSelected ? "▸" : " "
|
|
193
|
+
),
|
|
194
|
+
React.createElement(
|
|
195
|
+
Text,
|
|
196
|
+
{
|
|
197
|
+
color: item.disabled
|
|
198
|
+
? tokens.colors.muted
|
|
199
|
+
: isSelected
|
|
200
|
+
? tokens.colors.fg
|
|
201
|
+
: tokens.colors.fg,
|
|
202
|
+
dimColor: item.disabled,
|
|
203
|
+
bold: isSelected,
|
|
204
|
+
},
|
|
205
|
+
item.label
|
|
206
|
+
)
|
|
207
|
+
),
|
|
208
|
+
item.shortcut
|
|
209
|
+
? React.createElement(
|
|
210
|
+
Text,
|
|
211
|
+
{ color: tokens.colors.muted, dimColor: true },
|
|
212
|
+
item.shortcut
|
|
213
|
+
)
|
|
214
|
+
: null
|
|
215
|
+
);
|
|
216
|
+
})
|
|
217
|
+
)
|
|
218
|
+
: null
|
|
219
|
+
);
|
|
220
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { Box, Text, useInput } from "ink";
|
|
3
|
+
import type { Tokens } from "@hauktui/tokens";
|
|
4
|
+
import { useTokens, useFocusable } from "@hauktui/primitives-ink";
|
|
5
|
+
import { stableId, clamp } from "@hauktui/core";
|
|
6
|
+
|
|
7
|
+
export interface NavigationMenuItem {
|
|
8
|
+
/** Unique identifier */
|
|
9
|
+
id: string;
|
|
10
|
+
/** Display label */
|
|
11
|
+
label: string;
|
|
12
|
+
/** Optional href/path */
|
|
13
|
+
href?: string;
|
|
14
|
+
/** Child items for dropdown */
|
|
15
|
+
children?: NavigationMenuChild[];
|
|
16
|
+
/** Whether this item is disabled */
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface NavigationMenuChild {
|
|
21
|
+
/** Unique identifier */
|
|
22
|
+
id: string;
|
|
23
|
+
/** Display label */
|
|
24
|
+
label: string;
|
|
25
|
+
/** Description text */
|
|
26
|
+
description?: string;
|
|
27
|
+
/** Optional href/path */
|
|
28
|
+
href?: string;
|
|
29
|
+
/** Whether this item is disabled */
|
|
30
|
+
disabled?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface NavigationMenuProps {
|
|
34
|
+
/** Menu items */
|
|
35
|
+
items: NavigationMenuItem[];
|
|
36
|
+
/** Callback when an item is selected */
|
|
37
|
+
onSelect?: (item: NavigationMenuItem | NavigationMenuChild) => void;
|
|
38
|
+
/** Custom tokens override */
|
|
39
|
+
tokens?: Tokens;
|
|
40
|
+
/** Focus ID for focus management */
|
|
41
|
+
focusId?: string;
|
|
42
|
+
/** Orientation */
|
|
43
|
+
orientation?: "horizontal" | "vertical";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function NavigationMenu({
|
|
47
|
+
items,
|
|
48
|
+
onSelect,
|
|
49
|
+
tokens: propTokens,
|
|
50
|
+
focusId,
|
|
51
|
+
orientation = "horizontal",
|
|
52
|
+
}: NavigationMenuProps): React.ReactElement {
|
|
53
|
+
const contextTokens = useTokens();
|
|
54
|
+
const tokens = propTokens ?? contextTokens;
|
|
55
|
+
const id = focusId ?? stableId("navigation-menu");
|
|
56
|
+
const { isFocused } = useFocusable(id);
|
|
57
|
+
|
|
58
|
+
const [activeIndex, setActiveIndex] = useState(0);
|
|
59
|
+
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
|
60
|
+
const [childIndex, setChildIndex] = useState(0);
|
|
61
|
+
|
|
62
|
+
const activeItem = items.find((item) => item.id === openMenu);
|
|
63
|
+
|
|
64
|
+
useInput(
|
|
65
|
+
(input, key) => {
|
|
66
|
+
if (!isFocused) return;
|
|
67
|
+
|
|
68
|
+
if (key.escape) {
|
|
69
|
+
setOpenMenu(null);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!openMenu) {
|
|
74
|
+
// Top level navigation
|
|
75
|
+
if (orientation === "horizontal") {
|
|
76
|
+
if (key.leftArrow || input === "h") {
|
|
77
|
+
setActiveIndex((prev) => clamp(prev - 1, 0, items.length - 1));
|
|
78
|
+
} else if (key.rightArrow || input === "l") {
|
|
79
|
+
setActiveIndex((prev) => clamp(prev + 1, 0, items.length - 1));
|
|
80
|
+
}
|
|
81
|
+
} else {
|
|
82
|
+
if (key.upArrow || input === "k") {
|
|
83
|
+
setActiveIndex((prev) => clamp(prev - 1, 0, items.length - 1));
|
|
84
|
+
} else if (key.downArrow || input === "j") {
|
|
85
|
+
setActiveIndex((prev) => clamp(prev + 1, 0, items.length - 1));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (key.return || key.downArrow) {
|
|
90
|
+
const item = items[activeIndex];
|
|
91
|
+
if (item?.children && item.children.length > 0) {
|
|
92
|
+
setOpenMenu(item.id);
|
|
93
|
+
setChildIndex(0);
|
|
94
|
+
} else if (item) {
|
|
95
|
+
onSelect?.(item);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
} else {
|
|
99
|
+
// Child navigation
|
|
100
|
+
const children = activeItem?.children || [];
|
|
101
|
+
if (key.upArrow || input === "k") {
|
|
102
|
+
setChildIndex((prev) => clamp(prev - 1, 0, children.length - 1));
|
|
103
|
+
} else if (key.downArrow || input === "j") {
|
|
104
|
+
setChildIndex((prev) => clamp(prev + 1, 0, children.length - 1));
|
|
105
|
+
} else if (key.return) {
|
|
106
|
+
const child = children[childIndex];
|
|
107
|
+
if (child && !child.disabled) {
|
|
108
|
+
onSelect?.(child);
|
|
109
|
+
setOpenMenu(null);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
{ isActive: isFocused }
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const menuItems = items.map((item, index) => {
|
|
118
|
+
const isActive = index === activeIndex;
|
|
119
|
+
const isOpen = openMenu === item.id;
|
|
120
|
+
|
|
121
|
+
return React.createElement(
|
|
122
|
+
Text,
|
|
123
|
+
{
|
|
124
|
+
key: item.id,
|
|
125
|
+
color: item.disabled
|
|
126
|
+
? tokens.colors.muted
|
|
127
|
+
: isActive || isOpen
|
|
128
|
+
? tokens.colors.accent
|
|
129
|
+
: tokens.colors.fg,
|
|
130
|
+
bold: isActive || isOpen,
|
|
131
|
+
dimColor: item.disabled,
|
|
132
|
+
},
|
|
133
|
+
` ${item.label} `,
|
|
134
|
+
item.children ? "▾" : ""
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const dropdown = activeItem?.children
|
|
139
|
+
? React.createElement(
|
|
140
|
+
Box,
|
|
141
|
+
{
|
|
142
|
+
flexDirection: "column",
|
|
143
|
+
borderStyle: "round",
|
|
144
|
+
borderColor: tokens.colors.border,
|
|
145
|
+
marginTop: 0,
|
|
146
|
+
width: 35,
|
|
147
|
+
paddingY: 0,
|
|
148
|
+
},
|
|
149
|
+
...activeItem.children.map((child, index) => {
|
|
150
|
+
const isSelected = index === childIndex;
|
|
151
|
+
return React.createElement(
|
|
152
|
+
Box,
|
|
153
|
+
{
|
|
154
|
+
key: child.id,
|
|
155
|
+
flexDirection: "column",
|
|
156
|
+
paddingX: 1,
|
|
157
|
+
},
|
|
158
|
+
React.createElement(
|
|
159
|
+
Box,
|
|
160
|
+
{ gap: 1 },
|
|
161
|
+
React.createElement(
|
|
162
|
+
Text,
|
|
163
|
+
{
|
|
164
|
+
color: isSelected
|
|
165
|
+
? tokens.colors.accent
|
|
166
|
+
: tokens.colors.muted,
|
|
167
|
+
},
|
|
168
|
+
isSelected ? "▸" : " "
|
|
169
|
+
),
|
|
170
|
+
React.createElement(
|
|
171
|
+
Text,
|
|
172
|
+
{
|
|
173
|
+
color: child.disabled
|
|
174
|
+
? tokens.colors.muted
|
|
175
|
+
: isSelected
|
|
176
|
+
? tokens.colors.fg
|
|
177
|
+
: tokens.colors.fg,
|
|
178
|
+
bold: isSelected,
|
|
179
|
+
dimColor: child.disabled,
|
|
180
|
+
},
|
|
181
|
+
child.label
|
|
182
|
+
)
|
|
183
|
+
),
|
|
184
|
+
child.description
|
|
185
|
+
? React.createElement(
|
|
186
|
+
Text,
|
|
187
|
+
{ color: tokens.colors.muted, dimColor: true },
|
|
188
|
+
" ",
|
|
189
|
+
child.description
|
|
190
|
+
)
|
|
191
|
+
: null
|
|
192
|
+
);
|
|
193
|
+
})
|
|
194
|
+
)
|
|
195
|
+
: null;
|
|
196
|
+
|
|
197
|
+
return React.createElement(
|
|
198
|
+
Box,
|
|
199
|
+
{ flexDirection: "column" },
|
|
200
|
+
React.createElement(
|
|
201
|
+
Box,
|
|
202
|
+
{
|
|
203
|
+
flexDirection: orientation === "horizontal" ? "row" : "column",
|
|
204
|
+
borderStyle: "single",
|
|
205
|
+
borderColor: isFocused ? tokens.colors.focus : tokens.colors.border,
|
|
206
|
+
borderBottom: orientation === "horizontal",
|
|
207
|
+
borderRight: orientation === "vertical",
|
|
208
|
+
borderTop: false,
|
|
209
|
+
borderLeft: false,
|
|
210
|
+
gap: orientation === "horizontal" ? 1 : 0,
|
|
211
|
+
},
|
|
212
|
+
...menuItems
|
|
213
|
+
),
|
|
214
|
+
dropdown
|
|
215
|
+
);
|
|
216
|
+
}
|