style(layout): borderless WorkspaceSwitcher with hover/open background; fix avatar jump
Matches the crewli-starter SoT and fixes the recurring collapse jump at
its root cause. The prior structures left a residual avatar shift:
- the original split put the avatar at 24px expanded (wrapper px-4 +
card p-2) vs 16px collapsed (bare square) — an 8px horizontal jump;
- the interim single-trigger variant used wrapper p-[10px] + trigger
px-[10px] expanded (~20px) vs justify-center collapsed (16px) — a
~4px residual horizontal shift.
Unified both states to a single symmetric structure:
avatar offset = wrapper px-2 (8px) + trigger p-2 (8px) = 16px
16px from the rail's left edge in BOTH states — identical to the
SidebarHeader brand logo. Because the padding is symmetric (8 + 8 each
side) and the collapsed rail is 64px = 16 + 32 + 16, the left-aligned
avatar is also visually centred when collapsed — no justify-center,
no px swap, no horizontal shift; constant vertical padding, no vertical
shift. The jump is gone at the root.
Borderless: the trigger has NO border in any state (the prior is-open
border is dropped per the starter screenshots). The only divider is the
wrapper's border-t between the switcher and the nav. The grey
background is the sole fill — transparent at rest, grey on hover, and
grey while the popover is open (isOpen wired to Popover @show/@hide).
The trigger's p-2 gives the grey background generous padding around the
avatar+text, matching the starter's hover treatment, and since it is
the button's own background it never moves the content.
Specs reworked: trigger p-2 identical across states (no px swap / no
justify-center — the no-jump lock), wrapper carries p-2, trigger is
borderless at rest AND while open, open-state grey background applies
on @show and clears on @hide. Single-.trigger / rounded-lg / collapsed-
hides-meta+chev / sub-line specs retained.
Suite delta: 571 → 571 (specs reworked, count unchanged). vue-tsc
clean. Scoped ESLint clean (0 errors). Desktop only.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -161,43 +161,41 @@ function inviteUser(): void {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!--
|
<!--
|
||||||
Crewli-starter-faithful structure (P6-styling-switcher-sub follow-up).
|
Unified borderless structure (P6-styling-switcher-hover, crewli-starter
|
||||||
ONE `.trigger` button renders in BOTH states — collapsing only hides
|
SoT). ONE `.trigger` button renders in BOTH states; collapsing only
|
||||||
`.meta` + `.chev` (v-if) and recentres the lone avatar. This is the
|
hides `.meta` + `.chev` (v-if). The button keeps the SAME padding in
|
||||||
key fix for the vertical "avatar jump": the prior version swapped
|
both states — this is what kills the avatar jump:
|
||||||
between two separate <button> elements (a bare collapsed button vs a
|
|
||||||
padded expanded trigger) with different box models, so the avatar's
|
|
||||||
distance from the rail bottom differed by ~6px and visibly jumped on
|
|
||||||
collapse. With a single trigger and constant vertical padding
|
|
||||||
(`py-2` always), the avatar stays put.
|
|
||||||
|
|
||||||
Wrapper: `border-t` separator + `p-[10px]` ALWAYS (constant vertical
|
avatar offset = wrapper px-2 (8px) + trigger p-2 (8px) = 16px
|
||||||
padding → no jump; matches crewli-starter `.ws-switcher { padding:
|
|
||||||
10px }`). No border around the wrapper itself.
|
|
||||||
|
|
||||||
Centring equation (collapsed): wrapper p-[10px] gives the trigger a
|
16px from the rail's left edge in BOTH collapsed and expanded —
|
||||||
44px content width inside the 64px rail; the trigger's `justify-center
|
identical to the SidebarHeader brand logo (px-4). Because the
|
||||||
px-0` centres the 32px avatar → 16px from the rail's left edge,
|
horizontal padding is symmetric (8 + 8 each side) and the rail
|
||||||
aligned with the SidebarHeader brand logo above (also 16px).
|
collapses to 64px = 16 + 32 (avatar) + 16, the left-aligned avatar
|
||||||
|
is ALSO visually centred when collapsed — no justify-center swap, no
|
||||||
|
horizontal shift. Vertical padding is likewise constant, so no
|
||||||
|
vertical shift either. The earlier 24px-expanded / 16px-collapsed
|
||||||
|
split (and the later px-[10px]/justify-center variant) both left a
|
||||||
|
residual shift; this symmetric structure removes it entirely.
|
||||||
|
|
||||||
|
Borderless: the trigger has NO border in any state (the prior
|
||||||
|
is-open border is dropped per the starter screenshots). The only
|
||||||
|
divider is the wrapper's `border-t` separating the switcher from the
|
||||||
|
nav above. The grey background is the sole fill, on hover OR while
|
||||||
|
the popover is open.
|
||||||
-->
|
-->
|
||||||
<div class="ws-switcher relative flex-shrink-0 border-t border-[var(--p-content-border-color)] p-[10px]">
|
<div class="ws-switcher relative flex-shrink-0 border-t border-[var(--p-content-border-color)] p-2">
|
||||||
<!--
|
<!--
|
||||||
Trigger — single button for both states (crewli-starter `.trigger`).
|
Trigger — single button for both states. Transparent at rest; grey
|
||||||
Resting: transparent border + transparent bg. Hover: grey bg, no
|
on hover; grey KEPT while the popover is open (isOpen, wired to the
|
||||||
border. Open (is-open): grey bg KEPT + visible border, persisting
|
Popover @show/@hide). The `p-2` gives the grey background generous
|
||||||
until the popover closes — crewli-starter
|
padding around the avatar+text (crewli-starter hover treatment),
|
||||||
`.ws-switcher.is-open .trigger { background; border-color }`.
|
and because the background is the button's own bg (not a separate
|
||||||
`py-2` is constant; only horizontal padding / justify changes on
|
shifting layer) it never moves the avatar.
|
||||||
collapse, so the avatar never shifts vertically.
|
|
||||||
-->
|
-->
|
||||||
<button
|
<button
|
||||||
class="trigger flex w-full items-center gap-[10px] rounded-lg border bg-transparent py-2 text-[var(--p-text-color)] transition-[background,border-color] duration-150 focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--p-primary-color)] focus-visible:outline-offset-2"
|
class="trigger flex w-full items-center gap-[10px] rounded-lg bg-transparent p-2 text-[var(--p-text-color)] transition-colors duration-150 hover:bg-surface-100 dark:hover:bg-surface-800 focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--p-primary-color)] focus-visible:outline-offset-2"
|
||||||
:class="[
|
:class="{ 'bg-surface-100 dark:bg-surface-800': isOpen }"
|
||||||
collapsed ? 'justify-center px-0' : 'px-[10px]',
|
|
||||||
isOpen
|
|
||||||
? 'is-open border-[var(--p-content-border-color)] bg-[var(--p-content-hover-background)]'
|
|
||||||
: 'border-transparent hover:bg-[var(--p-content-hover-background)]',
|
|
||||||
]"
|
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
:aria-expanded="isOpen"
|
:aria-expanded="isOpen"
|
||||||
:aria-label="collapsed && current ? `Workspace: ${current.name}` : undefined"
|
:aria-label="collapsed && current ? `Workspace: ${current.name}` : undefined"
|
||||||
|
|||||||
@@ -113,10 +113,10 @@ describe('WorkspaceSwitcher', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Crewli-starter-faithful structure: ONE .trigger button in both states.
|
// Unified borderless structure (P6-styling-switcher-hover, crewli-starter
|
||||||
// Collapsing hides .meta + .chev and centres the lone avatar; it does NOT
|
// SoT). ONE .trigger button in both states with IDENTICAL padding
|
||||||
// swap to a separate bare button (that swap caused the vertical avatar
|
// (wrapper p-2 + trigger p-2 = avatar at 16px in both states → no jump).
|
||||||
// jump). The avatar square renders inside the same .trigger always.
|
// No border on the trigger; grey background on hover OR while open.
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
it('uses a single .trigger button in both states (no element swap on collapse)', () => {
|
it('uses a single .trigger button in both states (no element swap on collapse)', () => {
|
||||||
@@ -137,49 +137,68 @@ describe('WorkspaceSwitcher', () => {
|
|||||||
expect(wrapper.find('.trigger').classes()).toContain('rounded-lg')
|
expect(wrapper.find('.trigger').classes()).toContain('rounded-lg')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('expanded trigger has no justify-center (avatar + meta anchored on the left)', () => {
|
it('trigger padding (p-2) is identical across states — avatar stays at 16px (no jump)', () => {
|
||||||
const wrapper = mountSwitcher({ collapsed: false })
|
// The jump fix: the trigger keeps p-2 in BOTH states (no px swap, no
|
||||||
|
// justify-center). With the wrapper at p-2, the avatar offset is a
|
||||||
|
// constant 8 + 8 = 16px from the rail edge — no horizontal OR vertical
|
||||||
|
// shift on collapse.
|
||||||
|
const expanded = mountSwitcher({ collapsed: false })
|
||||||
|
const collapsed = mountSwitcher({ collapsed: true })
|
||||||
|
|
||||||
expect(wrapper.find('.trigger').classes()).not.toContain('justify-center')
|
expect(expanded.find('.trigger').classes()).toContain('p-2')
|
||||||
|
expect(collapsed.find('.trigger').classes()).toContain('p-2')
|
||||||
|
|
||||||
|
// No state-conditional horizontal padding / centring remains.
|
||||||
|
for (const w of [expanded, collapsed]) {
|
||||||
|
const cls = w.find('.trigger').classes()
|
||||||
|
|
||||||
|
expect(cls).not.toContain('justify-center')
|
||||||
|
expect(cls).not.toContain('px-0')
|
||||||
|
expect(cls).not.toContain('px-[10px]')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrapper carries p-2 too (the other half of the 16px offset).
|
||||||
|
expect(expanded.find('.ws-switcher').classes()).toContain('p-2')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('collapsed trigger centres the lone avatar and hides .meta + .chev', () => {
|
it('collapsed hides .meta + .chev; the avatar stays in the same p-2 trigger', () => {
|
||||||
const wrapper = mountSwitcher({ collapsed: true })
|
const wrapper = mountSwitcher({ collapsed: true })
|
||||||
|
|
||||||
const trigger = wrapper.find('.trigger')
|
const trigger = wrapper.find('.trigger')
|
||||||
|
|
||||||
expect(trigger.classes()).toContain('justify-center')
|
|
||||||
expect(wrapper.find('.meta').exists()).toBe(false)
|
expect(wrapper.find('.meta').exists()).toBe(false)
|
||||||
expect(wrapper.find('.chev').exists()).toBe(false)
|
expect(wrapper.find('.chev').exists()).toBe(false)
|
||||||
expect(trigger.find('.ws-logo').exists()).toBe(true)
|
expect(trigger.find('.ws-logo').exists()).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('trigger vertical padding (py-2) is constant across states (no avatar jump)', () => {
|
it('the trigger is borderless in every state (no card outline)', async () => {
|
||||||
// The vertical-jump fix: py-2 must be present in BOTH states so the
|
|
||||||
// avatar's vertical box is identical. Only horizontal padding/justify
|
|
||||||
// changes on collapse.
|
|
||||||
const expanded = mountSwitcher({ collapsed: false })
|
|
||||||
const collapsed = mountSwitcher({ collapsed: true })
|
|
||||||
|
|
||||||
expect(expanded.find('.trigger').classes()).toContain('py-2')
|
|
||||||
expect(collapsed.find('.trigger').classes()).toContain('py-2')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('open state keeps the trigger background + border (is-open persists hover bg)', async () => {
|
|
||||||
const wrapper = mountSwitcher({ collapsed: false })
|
const wrapper = mountSwitcher({ collapsed: false })
|
||||||
|
|
||||||
// Resting: transparent border, no is-open marker.
|
const restingHasBorder = wrapper.find('.trigger').classes().some(c => c.startsWith('border'))
|
||||||
expect(wrapper.find('.trigger').classes()).toContain('border-transparent')
|
|
||||||
expect(wrapper.find('.trigger').classes()).not.toContain('is-open')
|
|
||||||
|
|
||||||
// Simulate the Popover @show -> isOpen=true.
|
expect(restingHasBorder).toBe(false)
|
||||||
|
|
||||||
|
// Still borderless while open — the grey background is the only fill.
|
||||||
await wrapper.findComponent({ name: 'Popover' }).vm.$emit('show')
|
await wrapper.findComponent({ name: 'Popover' }).vm.$emit('show')
|
||||||
|
|
||||||
const trigger = wrapper.find('.trigger')
|
const openHasBorder = wrapper.find('.trigger').classes().some(c => c.startsWith('border'))
|
||||||
|
|
||||||
expect(trigger.classes()).toContain('is-open')
|
expect(openHasBorder).toBe(false)
|
||||||
expect(trigger.classes()).toContain('bg-[var(--p-content-hover-background)]')
|
})
|
||||||
expect(trigger.classes()).toContain('border-[var(--p-content-border-color)]')
|
|
||||||
|
it('open state applies the grey background and keeps it until hide', async () => {
|
||||||
|
const wrapper = mountSwitcher({ collapsed: false })
|
||||||
|
|
||||||
|
// Resting: no open-state background marker.
|
||||||
|
expect(wrapper.find('.trigger').classes()).not.toContain('bg-surface-100')
|
||||||
|
|
||||||
|
// @show -> isOpen=true -> grey background applied.
|
||||||
|
await wrapper.findComponent({ name: 'Popover' }).vm.$emit('show')
|
||||||
|
expect(wrapper.find('.trigger').classes()).toContain('bg-surface-100')
|
||||||
|
|
||||||
|
// @hide -> isOpen=false -> background removed.
|
||||||
|
await wrapper.findComponent({ name: 'Popover' }).vm.$emit('hide')
|
||||||
|
expect(wrapper.find('.trigger').classes()).not.toContain('bg-surface-100')
|
||||||
})
|
})
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user