Skip to content

Commit 905a52e

Browse files
committed
feat: Add GroupContainer with smart tab support
1 tab = plain header (title from tabs[0]), 2+ = tab bar. Group identity IS the first tab. Hover-only x, inline +, double-click rename. stopPropagation on keyboard events in rename inputs to prevent Cmd+Left bubbling.
1 parent c239b23 commit 905a52e

1 file changed

Lines changed: 304 additions & 0 deletions

File tree

Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
import { useState } from 'react';
2+
import { DashboardContainer } from '@hyperdx/common-utils/dist/types';
3+
import {
4+
ActionIcon,
5+
CloseButton,
6+
Flex,
7+
Input,
8+
Menu,
9+
Tabs,
10+
Text,
11+
} from '@mantine/core';
12+
import {
13+
IconDotsVertical,
14+
IconGripVertical,
15+
IconPlus,
16+
IconTrash,
17+
} from '@tabler/icons-react';
18+
19+
import { type DragHandleProps } from '@/components/DashboardDndContext';
20+
21+
type GroupContainerProps = {
22+
container: DashboardContainer;
23+
onDelete?: () => void;
24+
onAddTile?: () => void;
25+
activeTabId?: string;
26+
onTabChange?: (tabId: string) => void;
27+
onAddTab?: () => void;
28+
onRenameTab?: (tabId: string, newTitle: string) => void;
29+
onDeleteTab?: (tabId: string) => void;
30+
children: (activeTabId: string | undefined) => React.ReactNode;
31+
dragHandleProps?: DragHandleProps;
32+
};
33+
34+
export default function GroupContainer({
35+
container,
36+
onDelete,
37+
onAddTile,
38+
activeTabId,
39+
onTabChange,
40+
onAddTab,
41+
onRenameTab,
42+
onDeleteTab,
43+
children,
44+
dragHandleProps,
45+
}: GroupContainerProps) {
46+
const [editing, setEditing] = useState(false);
47+
const [editedTitle, setEditedTitle] = useState(container.title);
48+
const [hovered, setHovered] = useState(false);
49+
const [editingTabId, setEditingTabId] = useState<string | null>(null);
50+
const [editedTabTitle, setEditedTabTitle] = useState('');
51+
const [hoveredTabId, setHoveredTabId] = useState<string | null>(null);
52+
53+
const tabs = container.tabs ?? [];
54+
const hasTabs = tabs.length >= 2;
55+
const showControls = hovered;
56+
const resolvedActiveTabId = activeTabId ?? tabs[0]?.id;
57+
58+
const firstTab = tabs[0];
59+
const headerTitle = firstTab?.title ?? container.title;
60+
61+
const handleSaveRename = () => {
62+
const trimmed = editedTitle.trim();
63+
if (trimmed && firstTab && trimmed !== firstTab.title) {
64+
onRenameTab?.(firstTab.id, trimmed);
65+
} else {
66+
setEditedTitle(headerTitle);
67+
}
68+
setEditing(false);
69+
};
70+
71+
const handleSaveTabRename = (tabId: string) => {
72+
const trimmed = editedTabTitle.trim();
73+
const tab = tabs.find(t => t.id === tabId);
74+
if (trimmed && tab && trimmed !== tab.title) {
75+
onRenameTab?.(tabId, trimmed);
76+
}
77+
setEditingTabId(null);
78+
};
79+
80+
const addMenu = (
81+
<Menu width={200} position="bottom-end">
82+
<Menu.Target>
83+
<ActionIcon
84+
variant="subtle"
85+
size="xs"
86+
style={{
87+
opacity: showControls ? 1 : 0,
88+
pointerEvents: showControls ? 'auto' : 'none',
89+
}}
90+
data-testid={`group-add-menu-${container.id}`}
91+
>
92+
<IconPlus size={14} />
93+
</ActionIcon>
94+
</Menu.Target>
95+
<Menu.Dropdown>
96+
{onAddTile && <Menu.Item onClick={onAddTile}>Add Tile</Menu.Item>}
97+
{onAddTab && <Menu.Item onClick={onAddTab}>Add Tab</Menu.Item>}
98+
</Menu.Dropdown>
99+
</Menu>
100+
);
101+
102+
const deleteMenu = onDelete && (
103+
<Menu width={200} position="bottom-end">
104+
<Menu.Target>
105+
<ActionIcon
106+
variant="subtle"
107+
size="xs"
108+
style={{
109+
opacity: showControls ? 1 : 0,
110+
pointerEvents: showControls ? 'auto' : 'none',
111+
}}
112+
>
113+
<IconDotsVertical size={14} />
114+
</ActionIcon>
115+
</Menu.Target>
116+
<Menu.Dropdown>
117+
<Menu.Item
118+
leftSection={<IconTrash size={14} />}
119+
color="red"
120+
onClick={onDelete}
121+
>
122+
Delete Group
123+
</Menu.Item>
124+
</Menu.Dropdown>
125+
</Menu>
126+
);
127+
128+
const dragHandle = dragHandleProps && (
129+
<div
130+
{...dragHandleProps}
131+
style={{
132+
cursor: 'grab',
133+
display: 'flex',
134+
alignItems: 'center',
135+
padding: 2,
136+
flexShrink: 0,
137+
opacity: showControls ? 1 : 0,
138+
transition: 'opacity 150ms',
139+
}}
140+
data-testid={`group-drag-handle-${container.id}`}
141+
>
142+
<IconGripVertical
143+
size={14}
144+
style={{ color: 'var(--mantine-color-dimmed)' }}
145+
/>
146+
</div>
147+
);
148+
149+
return (
150+
<div
151+
data-testid={`group-container-${container.id}`}
152+
onMouseEnter={() => setHovered(true)}
153+
onMouseLeave={() => setHovered(false)}
154+
style={{
155+
border: '1px solid var(--mantine-color-default-border)',
156+
borderRadius: 4,
157+
marginTop: 8,
158+
}}
159+
>
160+
{hasTabs ? (
161+
/* Tab bar header (2+ tabs) — no separate title */
162+
<Tabs
163+
value={resolvedActiveTabId}
164+
onChange={val => val && onTabChange?.(val)}
165+
>
166+
<Flex
167+
align="center"
168+
px="sm"
169+
style={{
170+
borderBottom: '1px solid var(--mantine-color-default-border)',
171+
}}
172+
>
173+
{dragHandle}
174+
<Tabs.List style={{ flex: 1, border: 'none' }}>
175+
{tabs.map(tab => (
176+
<Tabs.Tab
177+
key={tab.id}
178+
value={tab.id}
179+
size="sm"
180+
onMouseEnter={() => setHoveredTabId(tab.id)}
181+
onMouseLeave={() => setHoveredTabId(null)}
182+
rightSection={
183+
onDeleteTab && tabs.length > 1 ? (
184+
<CloseButton
185+
size="xs"
186+
style={{
187+
opacity: hoveredTabId === tab.id ? 1 : 0,
188+
transition: 'opacity 150ms',
189+
}}
190+
onClick={e => {
191+
e.stopPropagation();
192+
onDeleteTab(tab.id);
193+
}}
194+
title="Remove tab"
195+
data-testid={`tab-close-${tab.id}`}
196+
/>
197+
) : undefined
198+
}
199+
onDoubleClick={
200+
onRenameTab
201+
? () => {
202+
setEditingTabId(tab.id);
203+
setEditedTabTitle(tab.title);
204+
}
205+
: undefined
206+
}
207+
>
208+
{editingTabId === tab.id ? (
209+
<form
210+
onSubmit={e => {
211+
e.preventDefault();
212+
handleSaveTabRename(tab.id);
213+
}}
214+
onClick={e => e.stopPropagation()}
215+
>
216+
<Input
217+
size="xs"
218+
value={editedTabTitle}
219+
onChange={e => setEditedTabTitle(e.currentTarget.value)}
220+
onBlur={() => handleSaveTabRename(tab.id)}
221+
onKeyDown={e => {
222+
e.stopPropagation();
223+
if (e.key === 'Escape') setEditingTabId(null);
224+
}}
225+
autoFocus
226+
styles={{ input: { minWidth: 60, height: 22 } }}
227+
data-testid={`tab-rename-input-${tab.id}`}
228+
/>
229+
</form>
230+
) : (
231+
tab.title
232+
)}
233+
</Tabs.Tab>
234+
))}
235+
</Tabs.List>
236+
{addMenu}
237+
{deleteMenu}
238+
</Flex>
239+
</Tabs>
240+
) : (
241+
/* Plain group header (1 tab) — uses tabs[0].title */
242+
<Flex
243+
align="center"
244+
gap="xs"
245+
px="sm"
246+
py={4}
247+
style={{
248+
borderBottom: '1px solid var(--mantine-color-default-border)',
249+
}}
250+
>
251+
{dragHandle}
252+
{editing ? (
253+
<form
254+
onSubmit={e => {
255+
e.preventDefault();
256+
handleSaveRename();
257+
}}
258+
style={{ flex: 1 }}
259+
>
260+
<Input
261+
size="xs"
262+
value={editedTitle}
263+
onChange={e => setEditedTitle(e.currentTarget.value)}
264+
onBlur={handleSaveRename}
265+
onKeyDown={e => {
266+
e.stopPropagation();
267+
if (e.key === 'Escape') {
268+
setEditedTitle(headerTitle);
269+
setEditing(false);
270+
}
271+
}}
272+
autoFocus
273+
data-testid={`group-rename-input-${container.id}`}
274+
/>
275+
</form>
276+
) : (
277+
<Text
278+
size="sm"
279+
fw={500}
280+
truncate
281+
style={{ flex: 1, cursor: onRenameTab ? 'text' : undefined }}
282+
onClick={
283+
onRenameTab
284+
? e => {
285+
e.stopPropagation();
286+
setEditedTitle(headerTitle);
287+
setEditing(true);
288+
}
289+
: undefined
290+
}
291+
>
292+
{headerTitle}
293+
</Text>
294+
)}
295+
{addMenu}
296+
{deleteMenu}
297+
</Flex>
298+
)}
299+
<div style={{ padding: 0 }}>
300+
{children(hasTabs ? resolvedActiveTabId : undefined)}
301+
</div>
302+
</div>
303+
);
304+
}

0 commit comments

Comments
 (0)