@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,114 @@
|
|
|
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 } from "@hauktui/core";
|
|
6
|
+
|
|
7
|
+
export interface ToggleProps {
|
|
8
|
+
/** Whether the toggle is on (controlled) */
|
|
9
|
+
value?: boolean;
|
|
10
|
+
/** Default value (uncontrolled) */
|
|
11
|
+
defaultValue?: boolean;
|
|
12
|
+
/** Callback when value changes */
|
|
13
|
+
onChange?: (value: boolean) => void;
|
|
14
|
+
/** Toggle label */
|
|
15
|
+
label?: string;
|
|
16
|
+
/** Whether the toggle is disabled */
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
/** Custom tokens override */
|
|
19
|
+
tokens?: Tokens;
|
|
20
|
+
/** Focus ID for focus management */
|
|
21
|
+
focusId?: string;
|
|
22
|
+
/** On label */
|
|
23
|
+
onLabel?: string;
|
|
24
|
+
/** Off label */
|
|
25
|
+
offLabel?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function Toggle({
|
|
29
|
+
value: controlledValue,
|
|
30
|
+
defaultValue = false,
|
|
31
|
+
onChange,
|
|
32
|
+
label,
|
|
33
|
+
disabled = false,
|
|
34
|
+
tokens: propTokens,
|
|
35
|
+
focusId,
|
|
36
|
+
onLabel = "ON",
|
|
37
|
+
offLabel = "OFF",
|
|
38
|
+
}: ToggleProps): React.ReactElement {
|
|
39
|
+
const contextTokens = useTokens();
|
|
40
|
+
const tokens = propTokens ?? contextTokens;
|
|
41
|
+
const id = focusId ?? stableId("toggle");
|
|
42
|
+
const { isFocused } = useFocusable(id);
|
|
43
|
+
|
|
44
|
+
const [internalValue, setInternalValue] = useState(defaultValue);
|
|
45
|
+
const isControlled = controlledValue !== undefined;
|
|
46
|
+
const isOn = isControlled ? controlledValue : internalValue;
|
|
47
|
+
|
|
48
|
+
const toggle = useCallback(() => {
|
|
49
|
+
if (disabled) return;
|
|
50
|
+
const newValue = !isOn;
|
|
51
|
+
if (!isControlled) {
|
|
52
|
+
setInternalValue(newValue);
|
|
53
|
+
}
|
|
54
|
+
onChange?.(newValue);
|
|
55
|
+
}, [disabled, isOn, isControlled, onChange]);
|
|
56
|
+
|
|
57
|
+
useInput(
|
|
58
|
+
(input, key) => {
|
|
59
|
+
if (!isFocused || disabled) return;
|
|
60
|
+
if (key.return || input === " ") {
|
|
61
|
+
toggle();
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
{ isActive: isFocused }
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const trackColor = disabled
|
|
68
|
+
? tokens.colors.disabled
|
|
69
|
+
: isOn
|
|
70
|
+
? tokens.colors.success
|
|
71
|
+
: tokens.colors.border;
|
|
72
|
+
|
|
73
|
+
const thumbPosition = isOn ? " ●" : "● ";
|
|
74
|
+
|
|
75
|
+
return React.createElement(
|
|
76
|
+
Box,
|
|
77
|
+
{ gap: 1 },
|
|
78
|
+
label
|
|
79
|
+
? React.createElement(
|
|
80
|
+
Text,
|
|
81
|
+
{
|
|
82
|
+
color: disabled ? tokens.colors.disabled : tokens.colors.fg,
|
|
83
|
+
bold: isFocused,
|
|
84
|
+
},
|
|
85
|
+
label
|
|
86
|
+
)
|
|
87
|
+
: null,
|
|
88
|
+
React.createElement(
|
|
89
|
+
Box,
|
|
90
|
+
{
|
|
91
|
+
borderStyle: "round",
|
|
92
|
+
borderColor: isFocused ? tokens.colors.focus : trackColor,
|
|
93
|
+
paddingX: 0,
|
|
94
|
+
},
|
|
95
|
+
React.createElement(
|
|
96
|
+
Text,
|
|
97
|
+
{ color: trackColor, bold: true },
|
|
98
|
+
thumbPosition
|
|
99
|
+
)
|
|
100
|
+
),
|
|
101
|
+
React.createElement(
|
|
102
|
+
Text,
|
|
103
|
+
{
|
|
104
|
+
color: disabled
|
|
105
|
+
? tokens.colors.disabled
|
|
106
|
+
: isOn
|
|
107
|
+
? tokens.colors.success
|
|
108
|
+
: tokens.colors.muted,
|
|
109
|
+
dimColor: disabled,
|
|
110
|
+
},
|
|
111
|
+
isOn ? onLabel : offLabel
|
|
112
|
+
)
|
|
113
|
+
);
|
|
114
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
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 ToggleGroupItem {
|
|
8
|
+
/** Unique value */
|
|
9
|
+
value: string;
|
|
10
|
+
/** Display label */
|
|
11
|
+
label: string;
|
|
12
|
+
/** Whether this item is disabled */
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ToggleGroupProps {
|
|
17
|
+
/** Available toggle items */
|
|
18
|
+
items: ToggleGroupItem[];
|
|
19
|
+
/** Controlled selected value(s) */
|
|
20
|
+
value?: string | string[];
|
|
21
|
+
/** Default value for uncontrolled mode */
|
|
22
|
+
defaultValue?: string | string[];
|
|
23
|
+
/** Callback when selection changes */
|
|
24
|
+
onChange?: (value: string | string[]) => void;
|
|
25
|
+
/** Allow multiple selection */
|
|
26
|
+
multiple?: boolean;
|
|
27
|
+
/** Whether the toggle group is disabled */
|
|
28
|
+
disabled?: boolean;
|
|
29
|
+
/** Custom tokens override */
|
|
30
|
+
tokens?: Tokens;
|
|
31
|
+
/** Focus ID for focus management */
|
|
32
|
+
focusId?: string;
|
|
33
|
+
/** Orientation */
|
|
34
|
+
orientation?: "horizontal" | "vertical";
|
|
35
|
+
/** Variant style */
|
|
36
|
+
variant?: "default" | "outline";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function ToggleGroup({
|
|
40
|
+
items,
|
|
41
|
+
value: controlledValue,
|
|
42
|
+
defaultValue = [],
|
|
43
|
+
onChange,
|
|
44
|
+
multiple = false,
|
|
45
|
+
disabled = false,
|
|
46
|
+
tokens: propTokens,
|
|
47
|
+
focusId,
|
|
48
|
+
orientation = "horizontal",
|
|
49
|
+
variant = "default",
|
|
50
|
+
}: ToggleGroupProps): React.ReactElement {
|
|
51
|
+
const contextTokens = useTokens();
|
|
52
|
+
const tokens = propTokens ?? contextTokens;
|
|
53
|
+
const id = focusId ?? stableId("toggle-group");
|
|
54
|
+
const { isFocused } = useFocusable(id);
|
|
55
|
+
|
|
56
|
+
const normalizeValue = (v: string | string[] | undefined): string[] => {
|
|
57
|
+
if (v === undefined) return [];
|
|
58
|
+
return Array.isArray(v) ? v : [v];
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const [internalValue, setInternalValue] = useState<string[]>(
|
|
62
|
+
normalizeValue(defaultValue)
|
|
63
|
+
);
|
|
64
|
+
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
|
65
|
+
|
|
66
|
+
const isControlled = controlledValue !== undefined;
|
|
67
|
+
const currentValue = normalizeValue(
|
|
68
|
+
isControlled ? controlledValue : internalValue
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const toggleItem = useCallback(
|
|
72
|
+
(itemValue: string) => {
|
|
73
|
+
const item = items.find((i) => i.value === itemValue);
|
|
74
|
+
if (!item || item.disabled || disabled) return;
|
|
75
|
+
|
|
76
|
+
let newValue: string[];
|
|
77
|
+
if (multiple) {
|
|
78
|
+
if (currentValue.includes(itemValue)) {
|
|
79
|
+
newValue = currentValue.filter((v) => v !== itemValue);
|
|
80
|
+
} else {
|
|
81
|
+
newValue = [...currentValue, itemValue];
|
|
82
|
+
}
|
|
83
|
+
} else {
|
|
84
|
+
newValue = currentValue.includes(itemValue) ? [] : [itemValue];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!isControlled) {
|
|
88
|
+
setInternalValue(newValue);
|
|
89
|
+
}
|
|
90
|
+
onChange?.(multiple ? newValue : newValue[0] || "");
|
|
91
|
+
},
|
|
92
|
+
[items, currentValue, multiple, disabled, isControlled, onChange]
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
useInput(
|
|
96
|
+
(input, key) => {
|
|
97
|
+
if (!isFocused || disabled) return;
|
|
98
|
+
|
|
99
|
+
const isHorizontal = orientation === "horizontal";
|
|
100
|
+
|
|
101
|
+
if (
|
|
102
|
+
(isHorizontal && key.leftArrow) ||
|
|
103
|
+
(!isHorizontal && key.upArrow) ||
|
|
104
|
+
input === "h" ||
|
|
105
|
+
input === "k"
|
|
106
|
+
) {
|
|
107
|
+
setHighlightedIndex((prev) => clamp(prev - 1, 0, items.length - 1));
|
|
108
|
+
} else if (
|
|
109
|
+
(isHorizontal && key.rightArrow) ||
|
|
110
|
+
(!isHorizontal && key.downArrow) ||
|
|
111
|
+
input === "l" ||
|
|
112
|
+
input === "j"
|
|
113
|
+
) {
|
|
114
|
+
setHighlightedIndex((prev) => clamp(prev + 1, 0, items.length - 1));
|
|
115
|
+
} else if (key.return || input === " ") {
|
|
116
|
+
const item = items[highlightedIndex];
|
|
117
|
+
if (item) {
|
|
118
|
+
toggleItem(item.value);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
{ isActive: isFocused }
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
return React.createElement(
|
|
126
|
+
Box,
|
|
127
|
+
{
|
|
128
|
+
flexDirection: orientation === "horizontal" ? "row" : "column",
|
|
129
|
+
borderStyle: isFocused ? "round" : "single",
|
|
130
|
+
borderColor: isFocused ? tokens.colors.focus : tokens.colors.border,
|
|
131
|
+
},
|
|
132
|
+
items.map((item, index) => {
|
|
133
|
+
const isSelected = currentValue.includes(item.value);
|
|
134
|
+
const isHighlighted = index === highlightedIndex && isFocused;
|
|
135
|
+
const isDisabled = item.disabled || disabled;
|
|
136
|
+
|
|
137
|
+
let bgColor: string | undefined;
|
|
138
|
+
let fgColor = tokens.colors.fg;
|
|
139
|
+
|
|
140
|
+
if (isDisabled) {
|
|
141
|
+
fgColor = tokens.colors.disabled;
|
|
142
|
+
} else if (isSelected) {
|
|
143
|
+
bgColor = tokens.colors.accent;
|
|
144
|
+
fgColor = tokens.colors.bg;
|
|
145
|
+
} else if (isHighlighted) {
|
|
146
|
+
fgColor = tokens.colors.accent;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return React.createElement(
|
|
150
|
+
Box,
|
|
151
|
+
{
|
|
152
|
+
key: item.value,
|
|
153
|
+
paddingX: 1,
|
|
154
|
+
borderStyle: variant === "outline" ? "single" : undefined,
|
|
155
|
+
borderColor:
|
|
156
|
+
variant === "outline"
|
|
157
|
+
? isSelected
|
|
158
|
+
? tokens.colors.accent
|
|
159
|
+
: tokens.colors.border
|
|
160
|
+
: undefined,
|
|
161
|
+
},
|
|
162
|
+
React.createElement(
|
|
163
|
+
Text,
|
|
164
|
+
{
|
|
165
|
+
backgroundColor: bgColor,
|
|
166
|
+
color: fgColor,
|
|
167
|
+
bold: isSelected || isHighlighted,
|
|
168
|
+
inverse: isHighlighted && !isSelected,
|
|
169
|
+
dimColor: isDisabled,
|
|
170
|
+
},
|
|
171
|
+
item.label
|
|
172
|
+
)
|
|
173
|
+
);
|
|
174
|
+
})
|
|
175
|
+
);
|
|
176
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import type { Tokens } from "@hauktui/tokens";
|
|
4
|
+
import { useTokens } from "@hauktui/primitives-ink";
|
|
5
|
+
|
|
6
|
+
export interface TooltipProps {
|
|
7
|
+
/** The content to show in the tooltip */
|
|
8
|
+
content: string;
|
|
9
|
+
/** The element that triggers the tooltip */
|
|
10
|
+
children: React.ReactNode;
|
|
11
|
+
/** Whether the tooltip is visible */
|
|
12
|
+
visible?: boolean;
|
|
13
|
+
/** Tooltip position */
|
|
14
|
+
position?: "top" | "bottom" | "left" | "right";
|
|
15
|
+
/** Custom tokens override */
|
|
16
|
+
tokens?: Tokens;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function Tooltip({
|
|
20
|
+
content,
|
|
21
|
+
children,
|
|
22
|
+
visible = false,
|
|
23
|
+
position = "top",
|
|
24
|
+
tokens: propTokens,
|
|
25
|
+
}: TooltipProps): React.ReactElement {
|
|
26
|
+
const contextTokens = useTokens();
|
|
27
|
+
const tokens = propTokens ?? contextTokens;
|
|
28
|
+
|
|
29
|
+
const tooltipElement = React.createElement(
|
|
30
|
+
Box,
|
|
31
|
+
{
|
|
32
|
+
borderStyle: "round",
|
|
33
|
+
borderColor: tokens.colors.border,
|
|
34
|
+
paddingX: 1,
|
|
35
|
+
backgroundColor: tokens.colors.bg,
|
|
36
|
+
},
|
|
37
|
+
React.createElement(Text, { color: tokens.colors.fg }, content)
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
if (!visible) {
|
|
41
|
+
return React.createElement(Box, null, children);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const containerProps: Record<string, unknown> = {
|
|
45
|
+
flexDirection:
|
|
46
|
+
position === "top" || position === "bottom" ? "column" : "row",
|
|
47
|
+
alignItems: "center",
|
|
48
|
+
gap: 0,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
if (position === "top") {
|
|
52
|
+
return React.createElement(Box, containerProps, tooltipElement, children);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (position === "bottom") {
|
|
56
|
+
return React.createElement(Box, containerProps, children, tooltipElement);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (position === "left") {
|
|
60
|
+
return React.createElement(Box, containerProps, tooltipElement, children);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// right
|
|
64
|
+
return React.createElement(Box, containerProps, children, tooltipElement);
|
|
65
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
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 } from "@hauktui/core";
|
|
6
|
+
|
|
7
|
+
export interface TreeNode {
|
|
8
|
+
/** Unique identifier */
|
|
9
|
+
id: string;
|
|
10
|
+
/** Display label */
|
|
11
|
+
label: string;
|
|
12
|
+
/** Icon to display (emoji or character) */
|
|
13
|
+
icon?: string;
|
|
14
|
+
/** Child nodes */
|
|
15
|
+
children?: TreeNode[];
|
|
16
|
+
/** Whether the node is disabled */
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface TreeViewProps {
|
|
21
|
+
/** Tree data structure */
|
|
22
|
+
data: TreeNode[];
|
|
23
|
+
/** Currently selected node ID */
|
|
24
|
+
selectedId?: string;
|
|
25
|
+
/** Callback when a node is selected */
|
|
26
|
+
onSelect?: (node: TreeNode) => void;
|
|
27
|
+
/** Initially expanded node IDs */
|
|
28
|
+
defaultExpanded?: string[];
|
|
29
|
+
/** Custom tokens override */
|
|
30
|
+
tokens?: Tokens;
|
|
31
|
+
/** Focus ID for focus management */
|
|
32
|
+
focusId?: string;
|
|
33
|
+
/** Show connecting lines */
|
|
34
|
+
showLines?: boolean;
|
|
35
|
+
/** Indent size in characters */
|
|
36
|
+
indentSize?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface FlatNode {
|
|
40
|
+
node: TreeNode;
|
|
41
|
+
depth: number;
|
|
42
|
+
isExpanded: boolean;
|
|
43
|
+
hasChildren: boolean;
|
|
44
|
+
isLast: boolean;
|
|
45
|
+
parentIsLast: boolean[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function TreeView({
|
|
49
|
+
data,
|
|
50
|
+
selectedId,
|
|
51
|
+
onSelect,
|
|
52
|
+
defaultExpanded = [],
|
|
53
|
+
tokens: propTokens,
|
|
54
|
+
focusId,
|
|
55
|
+
showLines = true,
|
|
56
|
+
indentSize = 2,
|
|
57
|
+
}: TreeViewProps): React.ReactElement {
|
|
58
|
+
const contextTokens = useTokens();
|
|
59
|
+
const tokens = propTokens ?? contextTokens;
|
|
60
|
+
const id = focusId ?? stableId("tree-view");
|
|
61
|
+
const { isFocused } = useFocusable(id);
|
|
62
|
+
|
|
63
|
+
const [expanded, setExpanded] = useState<Set<string>>(
|
|
64
|
+
new Set(defaultExpanded)
|
|
65
|
+
);
|
|
66
|
+
const [focusedIndex, setFocusedIndex] = useState(0);
|
|
67
|
+
|
|
68
|
+
// Flatten tree for rendering
|
|
69
|
+
const flattenTree = useCallback(
|
|
70
|
+
(
|
|
71
|
+
nodes: TreeNode[],
|
|
72
|
+
depth = 0,
|
|
73
|
+
parentIsLast: boolean[] = []
|
|
74
|
+
): FlatNode[] => {
|
|
75
|
+
const result: FlatNode[] = [];
|
|
76
|
+
|
|
77
|
+
nodes.forEach((node, index) => {
|
|
78
|
+
const isLast = index === nodes.length - 1;
|
|
79
|
+
const hasChildren = !!node.children && node.children.length > 0;
|
|
80
|
+
const isExpanded = expanded.has(node.id);
|
|
81
|
+
|
|
82
|
+
result.push({
|
|
83
|
+
node,
|
|
84
|
+
depth,
|
|
85
|
+
isExpanded,
|
|
86
|
+
hasChildren,
|
|
87
|
+
isLast,
|
|
88
|
+
parentIsLast,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
if (hasChildren && isExpanded) {
|
|
92
|
+
result.push(
|
|
93
|
+
...flattenTree(node.children!, depth + 1, [...parentIsLast, isLast])
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
return result;
|
|
99
|
+
},
|
|
100
|
+
[expanded]
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const flatNodes = flattenTree(data);
|
|
104
|
+
|
|
105
|
+
// Handle keyboard input
|
|
106
|
+
useInput(
|
|
107
|
+
(input, key) => {
|
|
108
|
+
if (!isFocused) return;
|
|
109
|
+
|
|
110
|
+
const currentNode = flatNodes[focusedIndex];
|
|
111
|
+
if (!currentNode) return;
|
|
112
|
+
|
|
113
|
+
if (key.upArrow || input === "k") {
|
|
114
|
+
setFocusedIndex((prev) => Math.max(0, prev - 1));
|
|
115
|
+
} else if (key.downArrow || input === "j") {
|
|
116
|
+
setFocusedIndex((prev) => Math.min(flatNodes.length - 1, prev + 1));
|
|
117
|
+
} else if (key.rightArrow || input === "l") {
|
|
118
|
+
if (currentNode.hasChildren && !currentNode.isExpanded) {
|
|
119
|
+
setExpanded((prev) => new Set([...prev, currentNode.node.id]));
|
|
120
|
+
}
|
|
121
|
+
} else if (key.leftArrow || input === "h") {
|
|
122
|
+
if (currentNode.hasChildren && currentNode.isExpanded) {
|
|
123
|
+
setExpanded((prev) => {
|
|
124
|
+
const next = new Set(prev);
|
|
125
|
+
next.delete(currentNode.node.id);
|
|
126
|
+
return next;
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
} else if (key.return || input === " ") {
|
|
130
|
+
if (!currentNode.node.disabled) {
|
|
131
|
+
if (currentNode.hasChildren) {
|
|
132
|
+
setExpanded((prev) => {
|
|
133
|
+
const next = new Set(prev);
|
|
134
|
+
if (next.has(currentNode.node.id)) {
|
|
135
|
+
next.delete(currentNode.node.id);
|
|
136
|
+
} else {
|
|
137
|
+
next.add(currentNode.node.id);
|
|
138
|
+
}
|
|
139
|
+
return next;
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
onSelect?.(currentNode.node);
|
|
143
|
+
}
|
|
144
|
+
} else if (input === "g") {
|
|
145
|
+
setFocusedIndex(0);
|
|
146
|
+
} else if (input === "G") {
|
|
147
|
+
setFocusedIndex(flatNodes.length - 1);
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
{ isActive: isFocused }
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
// Build line prefix
|
|
154
|
+
const buildPrefix = (flatNode: FlatNode): string => {
|
|
155
|
+
if (!showLines) {
|
|
156
|
+
return " ".repeat(flatNode.depth * indentSize);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let prefix = "";
|
|
160
|
+
|
|
161
|
+
for (let i = 0; i < flatNode.depth; i++) {
|
|
162
|
+
if (flatNode.parentIsLast[i]) {
|
|
163
|
+
prefix += " ".repeat(indentSize);
|
|
164
|
+
} else {
|
|
165
|
+
prefix += "│" + " ".repeat(indentSize - 1);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (flatNode.depth > 0) {
|
|
170
|
+
prefix = prefix.slice(0, -indentSize);
|
|
171
|
+
prefix += flatNode.isLast
|
|
172
|
+
? "└" + "─".repeat(indentSize - 1)
|
|
173
|
+
: "├" + "─".repeat(indentSize - 1);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return prefix;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// Get node icon
|
|
180
|
+
const getIcon = (flatNode: FlatNode): string => {
|
|
181
|
+
if (flatNode.node.icon) return flatNode.node.icon;
|
|
182
|
+
if (flatNode.hasChildren) {
|
|
183
|
+
return flatNode.isExpanded ? "▼" : "▶";
|
|
184
|
+
}
|
|
185
|
+
return "•";
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
return React.createElement(
|
|
189
|
+
Box,
|
|
190
|
+
{
|
|
191
|
+
flexDirection: "column",
|
|
192
|
+
borderStyle: isFocused ? "round" : "single",
|
|
193
|
+
borderColor: isFocused ? tokens.colors.focus : tokens.colors.border,
|
|
194
|
+
paddingX: 1,
|
|
195
|
+
},
|
|
196
|
+
flatNodes.map((flatNode, index) => {
|
|
197
|
+
const isSelected = selectedId === flatNode.node.id;
|
|
198
|
+
const isFocusedItem = isFocused && index === focusedIndex;
|
|
199
|
+
const prefix = buildPrefix(flatNode);
|
|
200
|
+
const icon = getIcon(flatNode);
|
|
201
|
+
|
|
202
|
+
return React.createElement(
|
|
203
|
+
Box,
|
|
204
|
+
{ key: flatNode.node.id },
|
|
205
|
+
React.createElement(
|
|
206
|
+
Text,
|
|
207
|
+
{
|
|
208
|
+
color: flatNode.node.disabled
|
|
209
|
+
? tokens.colors.muted
|
|
210
|
+
: isFocusedItem
|
|
211
|
+
? tokens.colors.accent
|
|
212
|
+
: tokens.colors.border,
|
|
213
|
+
},
|
|
214
|
+
prefix
|
|
215
|
+
),
|
|
216
|
+
React.createElement(
|
|
217
|
+
Text,
|
|
218
|
+
{
|
|
219
|
+
color: flatNode.node.disabled
|
|
220
|
+
? tokens.colors.muted
|
|
221
|
+
: isFocusedItem
|
|
222
|
+
? tokens.colors.accent
|
|
223
|
+
: isSelected
|
|
224
|
+
? tokens.colors.success
|
|
225
|
+
: tokens.colors.fg,
|
|
226
|
+
},
|
|
227
|
+
icon + " "
|
|
228
|
+
),
|
|
229
|
+
React.createElement(
|
|
230
|
+
Text,
|
|
231
|
+
{
|
|
232
|
+
color: flatNode.node.disabled
|
|
233
|
+
? tokens.colors.muted
|
|
234
|
+
: isFocusedItem || isSelected
|
|
235
|
+
? tokens.colors.accent
|
|
236
|
+
: tokens.colors.fg,
|
|
237
|
+
bold: isFocusedItem || isSelected,
|
|
238
|
+
dimColor: flatNode.node.disabled,
|
|
239
|
+
},
|
|
240
|
+
flatNode.node.label
|
|
241
|
+
)
|
|
242
|
+
);
|
|
243
|
+
})
|
|
244
|
+
);
|
|
245
|
+
}
|