feat(app): improve titlebar tab layout (#33914)

This commit is contained in:
Brendan Allan
2026-06-25 14:39:59 +00:00
committed by GitHub
parent 20fd323591
commit c097d51135
6 changed files with 219 additions and 213 deletions
@@ -22,7 +22,7 @@ export function captureTabDragLayout(list: HTMLElement, order: string[]) {
for (const slot of slots) { for (const slot of slots) {
const id = slot.dataset.tabKey const id = slot.dataset.tabKey
if (!id) continue if (!id) continue
const tab = slot.querySelector<HTMLElement>("[data-titlebar-tab]") const tab = slot.matches("[data-titlebar-tab]") ? slot : slot.querySelector<HTMLElement>("[data-titlebar-tab]")
if (!tab) continue if (!tab) continue
tabWidthById.set(id, tab.getBoundingClientRect().width) tabWidthById.set(id, tab.getBoundingClientRect().width)
} }
@@ -33,7 +33,7 @@ export function captureTabDragLayout(list: HTMLElement, order: string[]) {
const secondId = order[1] const secondId = order[1]
for (const slot of slots) { for (const slot of slots) {
if (slot.dataset.tabKey !== secondId) continue if (slot.dataset.tabKey !== secondId) continue
const tab = slot.querySelector<HTMLElement>("[data-titlebar-tab]") const tab = slot.matches("[data-titlebar-tab]") ? slot : slot.querySelector<HTMLElement>("[data-titlebar-tab]")
if (!tab) break if (!tab) break
const style = getComputedStyle(slot) const style = getComputedStyle(slot)
dividerWidth = dividerWidth =
@@ -1,20 +1,16 @@
[data-titlebar-tab] [data-slot="tab-close"] { [data-titlebar-tab] [data-slot="tab-close"] {
position: absolute; position: absolute;
top: 0; top: 4px;
right: 0; right: 4px;
display: flex; display: flex;
height: 28px; height: 20px;
width: 28px; width: 20px;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
[data-slot="titlebar-tabs"] {
container-type: inline-size;
}
[data-titlebar-tab-list] { [data-titlebar-tab-list] {
gap: calc(min(12px, 2cqw) + 1.5px); gap: 13.5px;
} }
[data-titlebar-tab-slot] { [data-titlebar-tab-slot] {
@@ -25,7 +21,7 @@
content: ""; content: "";
position: absolute; position: absolute;
top: 8px; top: 8px;
left: calc(0px - min(6px, 1cqw) - 0.75px); left: -6.75px;
width: 1.5px; width: 1.5px;
height: 12px; height: 12px;
border-radius: 9999px; border-radius: 9999px;
@@ -54,19 +50,11 @@
right: 100%; right: 100%;
} }
[data-titlebar-tab] [data-slot="tab-link"] {
padding-right: 22px;
}
[data-titlebar-tab][data-title-overflow="true"]:not(:hover):not([data-active="true"]):not([data-editing="true"]) [data-titlebar-tab][data-title-overflow="true"]:not(:hover):not([data-active="true"]):not([data-editing="true"])
[data-slot="tab-link"] { [data-slot="tab-link"] {
padding-right: 0; padding-right: 0;
} }
[data-titlebar-tab][data-editing="true"] [data-slot="tab-link"] {
padding-right: 22px;
}
[data-titlebar-tab] [data-slot="tab-title"] { [data-titlebar-tab] [data-slot="tab-title"] {
min-width: 0; min-width: 0;
} }
@@ -95,4 +83,10 @@
[data-titlebar-tab-title] { [data-titlebar-tab-title] {
display: none; display: none;
} }
[data-slot="tab-close"] {
right: auto;
left: 50%;
transform: translateX(-50%);
}
} }
@@ -29,6 +29,9 @@ export function TabNavItem(props: {
dragging?: boolean dragging?: boolean
pressed?: boolean pressed?: boolean
hidden?: boolean hidden?: boolean
tabKey: string
dragActive: boolean
onPointerDown: (event: PointerEvent) => void
}) { }) {
const language = useLanguage() const language = useLanguage()
const [editing, setEditing] = createSignal(false) const [editing, setEditing] = createSignal(false)
@@ -169,6 +172,13 @@ export function TabNavItem(props: {
}) })
return ( return (
<div
data-titlebar-tab-slot
data-tab-key={props.tabKey}
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
classList={{ invisible: props.hidden, "pointer-events-none": props.dragActive }}
onPointerDown={props.onPointerDown}
>
<div <div
ref={(el) => { ref={(el) => {
tabRoot = el tabRoot = el
@@ -178,8 +188,7 @@ export function TabNavItem(props: {
data-slot="titlebar-tab-item" data-slot="titlebar-tab-item"
data-title-overflow={titleOverflowing()} data-title-overflow={titleOverflowing()}
data-editing={editing()} data-editing={editing()}
class="group relative flex h-7 w-full min-w-0 max-w-56 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]" class="group relative flex h-7 w-full min-w-0 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
classList={{ invisible: props.hidden }}
data-active={props.active} data-active={props.active}
data-dragging={props.dragging} data-dragging={props.dragging}
data-pressed={props.pressed} data-pressed={props.pressed}
@@ -257,7 +266,7 @@ export function TabNavItem(props: {
}} }}
</Show> </Show>
<div data-slot="tab-close"> <div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
<IconButtonV2 <IconButtonV2
size="small" size="small"
variant="ghost-muted" variant="ghost-muted"
@@ -271,6 +280,7 @@ export function TabNavItem(props: {
/> />
</div> </div>
</div> </div>
</div>
) )
} }
@@ -285,6 +295,9 @@ export function DraftTabItem(props: {
dragging?: boolean dragging?: boolean
pressed?: boolean pressed?: boolean
hidden?: boolean hidden?: boolean
tabKey: string
dragActive: boolean
onPointerDown: (event: PointerEvent) => void
}) { }) {
const closeTab = (event: MouseEvent) => { const closeTab = (event: MouseEvent) => {
event.preventDefault() event.preventDefault()
@@ -292,6 +305,13 @@ export function DraftTabItem(props: {
props.onClose() props.onClose()
} }
return ( return (
<div
data-titlebar-tab-slot
data-tab-key={props.tabKey}
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
classList={{ invisible: props.hidden, "pointer-events-none": props.dragActive }}
onPointerDown={props.onPointerDown}
>
<div <div
ref={(el) => forwardTabRef(props.ref, el)} ref={(el) => forwardTabRef(props.ref, el)}
data-titlebar-tab data-titlebar-tab
@@ -299,8 +319,7 @@ export function DraftTabItem(props: {
data-active={props.active} data-active={props.active}
data-dragging={props.dragging} data-dragging={props.dragging}
data-pressed={props.pressed} data-pressed={props.pressed}
class="group relative flex h-7 w-full min-w-0 max-w-56 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--tab-bg)] pl-1.5 pr-8 [container-type:inline-size] whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-overlay-simple-overlay-pressed)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true'][data-pressed='true']:[--tab-bg:var(--v2-overlay-simple-overlay-pressed)]" class="group relative flex h-7 w-full min-w-0 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
classList={{ invisible: props.hidden }}
onMouseDown={(event) => { onMouseDown={(event) => {
if (event.button !== 1) return if (event.button !== 1) return
closeTab(event) closeTab(event)
@@ -320,16 +339,19 @@ export function DraftTabItem(props: {
if (props.suppressNavigation?.()) return if (props.suppressNavigation?.()) return
props.onNavigate() props.onNavigate()
}} }}
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden text-[13px] font-medium leading-5 text-v2-text-text-faint group-data-[active='true']:text-[var(--v2-text-text-base)]" class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base [-webkit-user-drag:none]"
> >
<span class="flex size-4 shrink-0 items-center justify-center"> <span class="flex size-4 shrink-0 items-center justify-center">
<IconV2 name="edit" /> <IconV2 name="edit" />
</span> </span>
<span data-titlebar-tab-title class="truncate leading-5"> <span
data-titlebar-tab-title
class="min-w-0 flex-1 overflow-hidden text-clip whitespace-nowrap outline-none leading-4"
>
{props.title} {props.title}
</span> </span>
</a> </a>
<div data-slot="tab-close" class="absolute right-0 inset-y-0 flex w-7 items-center justify-center"> <div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
<IconButtonV2 <IconButtonV2
size="small" size="small"
variant="ghost-muted" variant="ghost-muted"
@@ -341,11 +363,13 @@ export function DraftTabItem(props: {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
}} }}
class="hover-reveal relative z-10 group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100"
onClick={closeTab} onClick={closeTab}
icon={<IconV2 name="xmark-small" />} icon={<IconV2 name="xmark-small" />}
aria-label="Close tab" aria-label="Close tab"
/> />
</div> </div>
</div> </div>
</div>
) )
} }
@@ -100,17 +100,10 @@ function SessionTabSlot(props: {
}) })
return ( return (
<div
data-titlebar-tab-slot
data-tab-key={props.id}
class="flex min-w-0 max-w-56 flex-1 basis-0"
classList={{
hidden: !session(),
"pointer-events-none": props.dragActive,
}}
onPointerDown={props.onPointerDown}
>
<TabNavItem <TabNavItem
tabKey={props.id}
dragActive={props.dragActive}
onPointerDown={props.onPointerDown}
ref={ref} ref={ref}
href={tabHref(props.tab)} href={tabHref(props.tab)}
server={props.tab.server} server={props.tab.server}
@@ -132,9 +125,8 @@ function SessionTabSlot(props: {
forceTruncate={props.forceTruncate} forceTruncate={props.forceTruncate}
suppressNavigation={props.suppressNavigation} suppressNavigation={props.suppressNavigation}
pressed={props.pressed()} pressed={props.pressed()}
hidden={props.dragged()} hidden={props.dragged() || !session()}
/> />
</div>
) )
} }
@@ -336,7 +328,10 @@ export function TitlebarTabStrip(props: {
if (event.button !== 0 || drag.active) return if (event.button !== 0 || drag.active) return
if (!canStartTabDrag(event.pointerType)) return if (!canStartTabDrag(event.pointerType)) return
if (isTabCloseTarget(event.target)) return if (isTabCloseTarget(event.target)) return
const tabEl = (event.currentTarget as HTMLElement).querySelector<HTMLDivElement>("[data-titlebar-tab]") const target = event.currentTarget as HTMLDivElement
const tabEl = target.matches("[data-titlebar-tab]")
? target
: target.querySelector<HTMLDivElement>("[data-titlebar-tab]")
if (!tabEl) return if (!tabEl) return
if (!tabEl.querySelector('[data-slot="tab-link"]')) return if (!tabEl.querySelector('[data-slot="tab-link"]')) return
const tab = props.tabs.find((item) => tabKey(item) === id) const tab = props.tabs.find((item) => tabKey(item) === id)
@@ -459,13 +454,13 @@ export function TitlebarTabStrip(props: {
return ( return (
<> <>
<div data-slot="titlebar-tabs" class="relative min-w-0 flex-1"> <div data-slot="titlebar-tabs" class="relative min-w-0">
<div <div
data-slot="titlebar-tabs-scroll" data-slot="titlebar-tabs-scroll"
class="flex min-w-0 flex-row items-center gap-1.5 overflow-x-auto no-scrollbar [app-region:no-drag]" class="flex min-w-0 flex-row items-center gap-1.5 overflow-x-auto no-scrollbar [app-region:no-drag]"
ref={scrollRef} ref={scrollRef}
> >
<div data-titlebar-tab-list class="flex w-full min-w-0 flex-row items-center" ref={listRef}> <div data-titlebar-tab-list class="flex min-w-0 flex-row items-center" ref={listRef}>
<For each={displayTabs()}> <For each={displayTabs()}>
{(tab, index) => { {(tab, index) => {
const id = tabKey(tab) const id = tabKey(tab)
@@ -503,19 +498,13 @@ export function TitlebarTabStrip(props: {
} }
return ( return (
<div <DraftTabItem
data-titlebar-tab-slot tabKey={id}
data-tab-key={id} dragActive={drag.active}
class="flex min-w-0 max-w-56 flex-1 basis-0"
classList={{
"pointer-events-none": drag.active,
}}
onPointerDown={(event) => { onPointerDown={(event) => {
if (dragged()) return if (dragged()) return
onPointerDown(id, event) onPointerDown(id, event)
}} }}
>
<DraftTabItem
ref={ref} ref={ref}
href={tabHref(tab)} href={tabHref(tab)}
title={language.t("command.session.new")} title={language.t("command.session.new")}
@@ -526,7 +515,6 @@ export function TitlebarTabStrip(props: {
pressed={pressedId() === id} pressed={pressedId() === id}
hidden={dragged()} hidden={dragged()}
/> />
</div>
) )
}} }}
</For> </For>
+1
View File
@@ -481,6 +481,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
/> />
</TooltipV2> </TooltipV2>
</Show> </Show>
<div class="flex-1" />
<TitlebarV2Right state={v2RightState()} /> <TitlebarV2Right state={v2RightState()} />
<Show when={windows() && !electronWindows()}> <Show when={windows() && !electronWindows()}>
<div data-tauri-decorum-tb class="flex flex-row" /> <div data-tauri-decorum-tb class="flex flex-row" />
@@ -87,7 +87,6 @@
[data-slot="project-avatar-surface"] [data-slot="project-avatar-image"] { [data-slot="project-avatar-surface"] [data-slot="project-avatar-image"] {
position: relative; position: relative;
z-index: 1;
display: block; display: block;
width: 100%; width: 100%;
height: 100%; height: 100%;