feat(sidebar): inline label create, live sync, and kebab submenu parity (#706)

Portal-wrap DropdownMenuSubContent so the kebab Labels submenu renders outside
the clipped dropdown container, matching ContextMenuSubContent and fixing the
empty/broken kebab submenu.

Thread onLabelsChanged from LabelsSection through SettingsModal to
EditorLayout so label creates and deletes in Settings propagate to the
sidebar menus without a page refresh.

Add an inline "New label" form in both the kebab and context menu label
submenus that creates and assigns a label in one interaction, removing the
Settings round-trip from the label assignment flow.
This commit is contained in:
Anso
2026-04-20 09:57:20 -04:00
committed by GitHub
parent 5f2d67848c
commit 75370d8fce
9 changed files with 223 additions and 58 deletions
@@ -0,0 +1,79 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { LABEL_COLORS, type LabelColor } from '@/components/label-types';
interface LabelInlineCreateFormProps {
onSubmit: (name: string, color: LabelColor) => Promise<void>;
onCancel: () => void;
}
export function LabelInlineCreateForm({ onSubmit, onCancel }: LabelInlineCreateFormProps) {
const [name, setName] = useState('');
const [color, setColor] = useState<LabelColor>('teal');
const [saving, setSaving] = useState(false);
const submit = async () => {
const trimmed = name.trim();
if (!trimmed || saving) return;
setSaving(true);
try {
await onSubmit(trimmed, color);
} catch {
setSaving(false);
}
};
return (
<div
className="px-2 py-2 space-y-2"
onKeyDown={e => e.stopPropagation()}
onPointerDown={e => e.stopPropagation()}
>
<Input
placeholder="Label name"
value={name}
onChange={e => setName(e.target.value)}
className="h-7 text-xs font-mono"
maxLength={30}
autoFocus
onKeyDown={e => {
e.stopPropagation();
if (e.key === 'Enter') { e.preventDefault(); submit(); }
if (e.key === 'Escape') { e.preventDefault(); onCancel(); }
}}
/>
<div className="flex flex-wrap gap-1">
{LABEL_COLORS.map(c => (
<button
key={c}
type="button"
aria-label={`Color ${c}`}
className={`w-5 h-5 rounded-full border-2 transition-colors ${c === color ? 'border-foreground' : 'border-transparent hover:border-muted-foreground/30'}`}
style={{ backgroundColor: `var(--label-${c})` }}
onClick={() => setColor(c)}
/>
))}
</div>
<div className="flex gap-1">
<Button
size="sm"
className="h-6 text-xs flex-1"
onClick={submit}
disabled={saving || !name.trim()}
>
{saving ? 'Creating...' : 'Create'}
</Button>
<Button
size="sm"
variant="ghost"
className="h-6 text-xs"
onClick={onCancel}
disabled={saving}
>
Cancel
</Button>
</div>
</div>
);
}
@@ -1,13 +1,15 @@
import type { ReactNode } from 'react';
import { Check } from 'lucide-react';
import { useState, type ReactNode } from 'react';
import { Check, Plus } from 'lucide-react';
import {
ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator,
ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger,
} from '@/components/ui/context-menu';
import { LabelDot } from '@/components/LabelPill';
import { MAX_LABELS_PER_NODE } from '@/components/label-types';
import { cn } from '@/lib/utils';
import type { MenuGroup, MenuItem, StackMenuCtx } from './sidebar-types';
import { useStackMenuItems } from '@/hooks/useStackMenuItems';
import { LabelInlineCreateForm } from './LabelInlineCreateForm';
interface StackContextMenuProps {
file: string;
@@ -27,32 +29,51 @@ function GroupHeader({ id }: { id: string }) {
}
function LabelsSub({ item, ctx }: { item: MenuItem; ctx: StackMenuCtx }) {
const [creating, setCreating] = useState(false);
return (
<ContextMenuSub>
<ContextMenuSub onOpenChange={open => { if (!open) setCreating(false); }}>
<ContextMenuSubTrigger>
<item.icon className="h-4 w-4 mr-2" strokeWidth={1.5} />
{item.label}
</ContextMenuSubTrigger>
<ContextMenuSubContent className="min-w-[180px]">
{ctx.labels.length === 0 && (
<ContextMenuItem disabled>
<span className="text-xs text-muted-foreground">No labels yet</span>
</ContextMenuItem>
)}
{ctx.labels.map(label => {
const assigned = ctx.assignedLabelIds.includes(label.id);
return (
<ContextMenuItem key={label.id} onClick={() => ctx.toggleLabel(label.id)}>
<LabelDot color={label.color} />
<span className="flex-1 font-mono text-[12px] ml-2">{label.name}</span>
{assigned && <Check className="w-3.5 h-3.5 text-success ml-auto shrink-0" strokeWidth={1.5} />}
<ContextMenuSubContent className="min-w-[200px]">
{creating ? (
<LabelInlineCreateForm
onSubmit={async (name, color) => {
await ctx.createAndAssignLabel(name, color);
setCreating(false);
}}
onCancel={() => setCreating(false)}
/>
) : (
<>
{ctx.labels.length === 0 && (
<ContextMenuItem disabled>
<span className="text-xs text-muted-foreground">No labels yet</span>
</ContextMenuItem>
)}
{ctx.labels.map(label => {
const assigned = ctx.assignedLabelIds.includes(label.id);
return (
<ContextMenuItem key={label.id} onClick={() => ctx.toggleLabel(label.id)}>
<LabelDot color={label.color} />
<span className="flex-1 font-mono text-[12px] ml-2">{label.name}</span>
{assigned && <Check className="w-3.5 h-3.5 text-success ml-auto shrink-0" strokeWidth={1.5} />}
</ContextMenuItem>
);
})}
<ContextMenuSeparator />
{ctx.labels.length < MAX_LABELS_PER_NODE && (
<ContextMenuItem onSelect={e => { e.preventDefault(); setCreating(true); }}>
<Plus className="w-3.5 h-3.5 mr-2 text-muted-foreground" strokeWidth={1.5} />
<span className="text-xs">New label</span>
</ContextMenuItem>
)}
<ContextMenuItem onClick={ctx.openLabelManager}>
<span className="text-xs">Manage labels...</span>
</ContextMenuItem>
);
})}
<ContextMenuSeparator />
<ContextMenuItem onClick={ctx.openLabelManager}>
<span className="text-xs">Manage labels...</span>
</ContextMenuItem>
</>
)}
</ContextMenuSubContent>
</ContextMenuSub>
);
@@ -1,13 +1,16 @@
import { MoreVertical, Check } from 'lucide-react';
import { useState } from 'react';
import { MoreVertical, Check, Plus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator,
DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { LabelDot } from '@/components/LabelPill';
import { MAX_LABELS_PER_NODE } from '@/components/label-types';
import { cn } from '@/lib/utils';
import type { MenuGroup, MenuItem, StackMenuCtx } from './sidebar-types';
import { useStackMenuItems } from '@/hooks/useStackMenuItems';
import { LabelInlineCreateForm } from './LabelInlineCreateForm';
interface StackKebabMenuProps {
file: string;
@@ -26,32 +29,51 @@ function GroupHeader({ id }: { id: string }) {
}
function LabelsSub({ item, ctx }: { item: MenuItem; ctx: StackMenuCtx }) {
const [creating, setCreating] = useState(false);
return (
<DropdownMenuSub>
<DropdownMenuSub onOpenChange={open => { if (!open) setCreating(false); }}>
<DropdownMenuSubTrigger>
<item.icon className="h-4 w-4 mr-2" strokeWidth={1.5} />
{item.label}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="min-w-[180px]">
{ctx.labels.length === 0 && (
<DropdownMenuItem disabled>
<span className="text-xs text-muted-foreground">No labels yet</span>
</DropdownMenuItem>
)}
{ctx.labels.map(label => {
const assigned = ctx.assignedLabelIds.includes(label.id);
return (
<DropdownMenuItem key={label.id} onSelect={(e) => { e.preventDefault(); ctx.toggleLabel(label.id); }}>
<LabelDot color={label.color} />
<span className="flex-1 font-mono text-[12px] ml-2">{label.name}</span>
{assigned && <Check className="w-3.5 h-3.5 text-success ml-auto shrink-0" strokeWidth={1.5} />}
<DropdownMenuSubContent className="min-w-[200px]">
{creating ? (
<LabelInlineCreateForm
onSubmit={async (name, color) => {
await ctx.createAndAssignLabel(name, color);
setCreating(false);
}}
onCancel={() => setCreating(false)}
/>
) : (
<>
{ctx.labels.length === 0 && (
<DropdownMenuItem disabled>
<span className="text-xs text-muted-foreground">No labels yet</span>
</DropdownMenuItem>
)}
{ctx.labels.map(label => {
const assigned = ctx.assignedLabelIds.includes(label.id);
return (
<DropdownMenuItem key={label.id} onSelect={(e) => { e.preventDefault(); ctx.toggleLabel(label.id); }}>
<LabelDot color={label.color} />
<span className="flex-1 font-mono text-[12px] ml-2">{label.name}</span>
{assigned && <Check className="w-3.5 h-3.5 text-success ml-auto shrink-0" strokeWidth={1.5} />}
</DropdownMenuItem>
);
})}
<DropdownMenuSeparator />
{ctx.labels.length < MAX_LABELS_PER_NODE && (
<DropdownMenuItem onSelect={e => { e.preventDefault(); setCreating(true); }}>
<Plus className="w-3.5 h-3.5 mr-2 text-muted-foreground" strokeWidth={1.5} />
<span className="text-xs">New label</span>
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={ctx.openLabelManager}>
<span className="text-xs">Manage labels...</span>
</DropdownMenuItem>
);
})}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={ctx.openLabelManager}>
<span className="text-xs">Manage labels...</span>
</DropdownMenuItem>
</>
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
);
@@ -1,5 +1,5 @@
import type { LucideIcon } from 'lucide-react';
import type { Label } from '../label-types';
import type { Label, LabelColor } from '../label-types';
export type MenuGroupId = 'inspect' | 'organize' | 'lifecycle' | 'destructive';
@@ -43,6 +43,7 @@ export interface StackMenuCtx {
pin: () => void;
unpin: () => void;
toggleLabel: (labelId: number) => void;
createAndAssignLabel: (name: string, color: LabelColor) => Promise<void>;
openLabelManager: () => void;
}