diff --git a/apps/studio/components/layouts/Tabs/SortableTab.tsx b/apps/studio/components/layouts/Tabs/SortableTab.tsx index 4e9a99d2095..23240722b91 100644 --- a/apps/studio/components/layouts/Tabs/SortableTab.tsx +++ b/apps/studio/components/layouts/Tabs/SortableTab.tsx @@ -2,7 +2,7 @@ import { useSortable } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' import { AnimatePresence, motion } from 'framer-motion' import { X } from 'lucide-react' -import { useMemo } from 'react' +import { useMemo, type KeyboardEvent } from 'react' import { cn, TabsTrigger } from 'ui' import { useEditorType } from '../editors/EditorsLayout.hooks' @@ -17,6 +17,14 @@ import { useTabsStateSnapshot, type Tab } from '@/state/tabs' * - Dynamic schema name display * - Tab label animations * - Close button interactions + * + * Markup: sortable shell (plain div) → TabsTrigger + close as siblings. + * dnd-kit `attributes` are intentionally not spread on the shell — they inject + * `role="button"` / `tabIndex={0}`, which nested a second button around the tab. + * Only PointerSensor is used for reorder, so those attributes are not required. + * + * Keyboard: ←/→ move between tabs (Radix). Delete/Backspace on a focused tab + * closes it. The active tab's close button is in the tab order. */ export const SortableTab = ({ tab, @@ -37,7 +45,7 @@ export const SortableTab = ({ void tabs.handlerRegistrationVersion const StatusIndicator = tabs.getTabStatusIndicator(tab.type) const { selectedSchema: currentSchema } = useQuerySchemaState() - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + const { setNodeRef, listeners, transform, transition, isDragging } = useSortable({ id: tab.id, }) @@ -54,89 +62,109 @@ export const SortableTab = ({ return openTabs.some((t) => editor === 'table' && t.metadata?.schema !== currentSchema) }, [openTabs, currentSchema, editor]) - // Create a motion version of TabsTrigger while preserving all functionality - // const MotionTabsTrigger = motion(TabsTrigger) + const isActive = tabs.activeTab === tab.id + + const closeTabFromKeyboard = (event: KeyboardEvent) => { + if (event.key !== 'Delete' && event.key !== 'Backspace') return + event.preventDefault() + event.stopPropagation() + onClose(tab.id) + } return ( - { - // Middle click closes tab - if (e.button === 1) { - e.preventDefault() - onClose(tab.id) - } - }} - onDoubleClick={() => tabs.makeTabPermanent(tab.id)} - className={cn( - 'flex items-center gap-2 pl-3 pr-2.5 text-xs', - 'bg-dash-sidebar/50 dark:bg-surface-100/50', - 'data-[state=active]:bg-dash-sidebar dark:data-[state=active]:bg-surface-100', - 'border-b border-default', - 'data-[state=active]:border-b-background-dash-sidebar dark:data-[state=active]:border-b-background-surface-100', - 'relative group h-full', - 'hover:bg-surface-300 dark:hover:bg-surface-100', - tab.isPreview && 'italic font-light' // Optional: style preview tabs differently - )} - {...listeners} - > - -
- - {shouldShowSchema && ( - - {tab?.metadata?.schema}. - - )} - - {tab.label || 'Untitled'} -
- {/* VS Code-style slot: the type's status indicator (e.g. an unsaved dot) - shows at rest and swaps to the close button on hover. */} -
- {StatusIndicator && ( - - - - )} - { +
+ { + // Middle click closes tab + if (e.button === 1) { e.preventDefault() - e.stopPropagation() - }} - className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 hover:bg-200 rounded-xs cursor-pointer" - onMouseDown={(e) => { - e.preventDefault() - e.stopPropagation() - }} - onPointerDown={(e) => { - e.preventDefault() - e.stopPropagation() onClose(tab.id) - }} + } + }} + onDoubleClick={() => tabs.makeTabPermanent(tab.id)} + onKeyDown={closeTabFromKeyboard} + className={cn( + 'flex items-center gap-2 pl-3 pr-2.5 text-xs', + 'bg-dash-sidebar/50 dark:bg-surface-100/50', + 'data-[state=active]:bg-dash-sidebar dark:data-[state=active]:bg-surface-100', + 'border-b border-default', + 'data-[state=active]:border-b-background-dash-sidebar dark:data-[state=active]:border-b-background-surface-100', + 'relative group h-full', + 'hover:bg-surface-300 dark:hover:bg-surface-100', + tab.isPreview && 'italic font-light' // Optional: style preview tabs differently + )} + {...listeners} + > + +
+ + {shouldShowSchema && ( + + {tab?.metadata?.schema}. + + )} + + {tab.label || 'Untitled'} +
+ {/* Reserve status/close slot width; close is a sibling overlay, not nested. */} +
- - -
-
- + {StatusIndicator && ( + + + + )} +
+
+ + {/* Sibling of TabsTrigger — not nested inside the tab button. + Only the active tab's close is in the tab order (roving tabs). Delete/Backspace + on the focused tab also closes. */} + +
{index < openTabs.length && (
)} diff --git a/apps/studio/components/layouts/Tabs/Tabs.tsx b/apps/studio/components/layouts/Tabs/Tabs.tsx index c1959e98717..72b06e913ac 100644 --- a/apps/studio/components/layouts/Tabs/Tabs.tsx +++ b/apps/studio/components/layouts/Tabs/Tabs.tsx @@ -214,27 +214,40 @@ export const EditorTabs = () => { {/* Non-draggable new tab */} {hasNewTab && ( - - -
- New -
- + { + if (e.key !== 'Delete' && e.key !== 'Backspace') return + e.preventDefault() + e.stopPropagation() + handleClose('new') + }} + className={cn( + 'flex items-center gap-2 px-3 text-xs', + 'bg-dash-sidebar/50 dark:bg-surface-100/50', + 'data-[state=active]:bg-dash-sidebar dark:data-[state=active]:bg-surface-100', + 'relative group h-full border-t-2 border-b-0!', + 'hover:bg-surface-300 dark:hover:bg-surface-100' + )} + > + +
+ New +
+ {/* Reserve close-icon width; close is a sibling overlay. */} + +
+ + +
)}