feat(client): add ContextMenu component, environment cards, detail page, and breadcrumbs
- add ContextMenu component with click-outside/Escape dismiss, align prop, and danger variant - replace EnvironmentsPage table with responsive card grid - add cog button to each card opening a context menu with view and delete actions - add EnvironmentDetailPage at /environments/:id with meta grid, URLs card, and delete - add Breadcrumbs to all pages; detail page uses two-item trail with delete button inline - add Table stories for pagination and Timestamp column
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
.root {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.triggerWrap {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: calc(100% + var(--space-1));
|
||||
z-index: 100;
|
||||
min-width: 160px;
|
||||
margin: 0;
|
||||
padding: var(--space-1) 0;
|
||||
list-style: none;
|
||||
background: var(--color-bg-subtle);
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.alignRight { right: 0; }
|
||||
.alignLeft { left: 0; }
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background: transparent;
|
||||
border: none;
|
||||
text-align: left;
|
||||
font-family: var(--font-family);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition), color var(--transition);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.item:hover {
|
||||
background: var(--color-secondary);
|
||||
}
|
||||
|
||||
.item.danger {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.item.danger:hover {
|
||||
background: var(--color-error-bg);
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
opacity: 0.75;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import styles from './ContextMenu.module.css';
|
||||
|
||||
export interface ContextMenuItem {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
variant?: 'default' | 'danger';
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface ContextMenuProps {
|
||||
items: ContextMenuItem[];
|
||||
/** The trigger element — a button rendered by the component */
|
||||
trigger: React.ReactNode;
|
||||
align?: 'left' | 'right';
|
||||
}
|
||||
|
||||
export function ContextMenu({ items, trigger, align = 'right' }: ContextMenuProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleOutside(e: MouseEvent) {
|
||||
if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleOutside);
|
||||
return () => document.removeEventListener('mousedown', handleOutside);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') setOpen(false);
|
||||
}
|
||||
document.addEventListener('keydown', handleKey);
|
||||
return () => document.removeEventListener('keydown', handleKey);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div className={styles.root} ref={rootRef}>
|
||||
<div
|
||||
className={styles.triggerWrap}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpen((v) => !v);
|
||||
}}
|
||||
>
|
||||
{trigger}
|
||||
</div>
|
||||
{open && (
|
||||
<ul
|
||||
className={`${styles.menu} ${align === 'left' ? styles.alignLeft : styles.alignRight}`}
|
||||
role="menu"
|
||||
>
|
||||
{items.map((item, i) => (
|
||||
<li key={i} role="none">
|
||||
<button
|
||||
role="menuitem"
|
||||
className={`${styles.item} ${item.variant === 'danger' ? styles.danger : ''}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpen(false);
|
||||
item.onClick();
|
||||
}}
|
||||
>
|
||||
{item.icon && (
|
||||
<span className={styles.icon} aria-hidden="true">
|
||||
{item.icon}
|
||||
</span>
|
||||
)}
|
||||
{item.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -27,5 +27,8 @@ export type { TimestampProps } from './Timestamp/Timestamp';
|
||||
export { Pagination } from './Pagination/Pagination';
|
||||
export type { PaginationProps } from './Pagination/Pagination';
|
||||
|
||||
export { ContextMenu } from './ContextMenu/ContextMenu';
|
||||
export type { ContextMenuProps, ContextMenuItem } from './ContextMenu/ContextMenu';
|
||||
|
||||
export { Table } from './Table/Table';
|
||||
export type { TableProps, TableColumn } from './Table/Table';
|
||||
|
||||
Reference in New Issue
Block a user