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>
346 lines
14 KiB
Vue
346 lines
14 KiB
Vue
<script setup lang="ts">
|
|
/**
|
|
* WorkspaceSwitcher — ported from crewli-starter WorkspaceSwitcher.vue.
|
|
*
|
|
* Data: read-only from useAuthStore (organisations, currentOrganisation,
|
|
* setActiveOrganisation). This component owns NO org state — RFC AD-G4.
|
|
*
|
|
* Popover: PrimeVue <Popover> replaces the manual document.mousedown
|
|
* click-outside listener from crewli-starter. Toggle via popoverRef.toggle($event).
|
|
*
|
|
* Plan 2.5 P5 Fix 5 (RFC §5.5): dropdown panel restructured per
|
|
* crewli-starter reference, with AD-2.5-W1 option A applied — dropdown
|
|
* rows render WITHOUT a sub line (organisation type / metrics deferred
|
|
* to a future RFC with backend scope — tracked as
|
|
* WORKSPACE-DROPDOWN-SUB-CONTENT). Known visual divergence: rows are
|
|
* ~16px shorter than crewli-starter; accepted.
|
|
*
|
|
* Semantic class names (.popover-head, .title, .link, .list, .opt,
|
|
* .is-current, .ws-logo, .name, .check-mark, .foot) are kept alongside
|
|
* the Tailwind utility classes as structural markers — they carry no
|
|
* scoped CSS rules (only the .ws-logo / .ws-logo-square-lg box-shadow
|
|
* exceptions below) and exist so specs can assert structure with
|
|
* stable selectors.
|
|
*
|
|
* Icons: Crewli Icon.vue convention — name="tabler-x" :size="N".
|
|
*
|
|
* Styling: crewli-starter CSS selectors translated to Tailwind utilities inline.
|
|
* One <style scoped> block covers the two box-shadow exceptions documented below.
|
|
*/
|
|
|
|
import Popover from 'primevue/popover'
|
|
import { computed, ref } from 'vue'
|
|
import Icon from '@/components/Icon.vue'
|
|
import { useAuthStore } from '@/stores/useAuthStore'
|
|
import { computeOrgGradient } from '@/utils/v2/gradient'
|
|
import type { Organisation } from '@/types/auth'
|
|
|
|
defineProps<{
|
|
/**
|
|
* When true (collapsed sidebar), hide the name meta text and
|
|
* show only the logo square — mirrors crewli-starter's collapsed prop.
|
|
*/
|
|
collapsed?: boolean
|
|
}>()
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Store
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const authStore = useAuthStore()
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Derived current-workspace display object
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface WorkspaceDisplay {
|
|
id: string
|
|
initials: string
|
|
name: string
|
|
/**
|
|
* Light-grey second line under the name. Plan 2.5 P6-styling-switcher-sub
|
|
* re-adds the two-line layout (reverses AD-2.5-W1 option A) after visual
|
|
* review against the crewli-starter SoT. The value is a neutral
|
|
* PLACEHOLDER — the real org type + metrics (e.g. "Festival · 12 days ·
|
|
* 14 stages") require the organisations.type enum + a metrics endpoint,
|
|
* which stay deferred under WORKSPACE-DROPDOWN-SUB-CONTENT. The org
|
|
* object exposes no field that reads well as a subtitle today (only
|
|
* id/name/slug/role; role is an access identifier, not a description),
|
|
* so a static neutral string is used rather than fabricated metrics.
|
|
*/
|
|
sub: string
|
|
gradient: [string, string]
|
|
}
|
|
|
|
// TODO: replace with org type + metrics when WORKSPACE-DROPDOWN-SUB-CONTENT backend lands
|
|
const SUB_PLACEHOLDER = 'Organisatie'
|
|
|
|
function buildDisplay(org: Organisation): WorkspaceDisplay {
|
|
const words = org.name.trim().split(/\s+/)
|
|
|
|
const initials
|
|
= (words.length >= 2
|
|
? (words[0][0] + words[1][0]).toUpperCase()
|
|
: org.name.slice(0, 2).toUpperCase()) || '?'
|
|
|
|
return {
|
|
id: org.id,
|
|
initials,
|
|
name: org.name,
|
|
sub: SUB_PLACEHOLDER,
|
|
gradient: computeOrgGradient(org.id),
|
|
}
|
|
}
|
|
|
|
const current = computed<WorkspaceDisplay | null>(() => {
|
|
const org = authStore.currentOrganisation
|
|
|
|
return org ? buildDisplay(org) : null
|
|
})
|
|
|
|
// Sorted list: active org first, then the rest alphabetically
|
|
const allOrgs = computed<WorkspaceDisplay[]>(() => {
|
|
const currentId = authStore.currentOrganisation?.id
|
|
|
|
return [...authStore.organisations]
|
|
.sort((a, b) => {
|
|
if (a.id === currentId)
|
|
return -1
|
|
if (b.id === currentId)
|
|
return 1
|
|
|
|
return a.name.localeCompare(b.name)
|
|
})
|
|
.map(buildDisplay)
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Popover plumbing — PrimeVue Popover replaces the manual mousedown listener
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const popoverRef = ref<InstanceType<typeof Popover> | null>(null)
|
|
|
|
/**
|
|
* Tracks the popover open state so the trigger keeps the grey "active"
|
|
* background + visible border while the workspace dropdown is open
|
|
* (crewli-starter `.ws-switcher.is-open .trigger`). Synced from the
|
|
* PrimeVue Popover's @show/@hide events rather than mirroring the manual
|
|
* toggle, so programmatic hides (selectOrg) and outside-click dismissal
|
|
* both keep it accurate.
|
|
*/
|
|
const isOpen = ref(false)
|
|
|
|
function toggle(event: MouseEvent): void {
|
|
popoverRef.value?.toggle(event)
|
|
}
|
|
|
|
function selectOrg(ws: WorkspaceDisplay): void {
|
|
if (ws.id === authStore.currentOrganisation?.id) {
|
|
popoverRef.value?.hide()
|
|
|
|
return
|
|
}
|
|
authStore.setActiveOrganisation(ws.id)
|
|
popoverRef.value?.hide()
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Footer button placeholders — neither flow is wired in Plan 2.5 scope.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// TODO: wire to /v2/workspaces/new route or create-workspace dialog when ready
|
|
function createWorkspace(): void {
|
|
console.warn('WorkspaceSwitcher: createWorkspace not yet implemented')
|
|
}
|
|
|
|
// TODO: wire to invite-user dialog when ready
|
|
function inviteUser(): void {
|
|
console.warn('WorkspaceSwitcher: inviteUser not yet implemented')
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<!--
|
|
Unified borderless structure (P6-styling-switcher-hover, crewli-starter
|
|
SoT). ONE `.trigger` button renders in BOTH states; collapsing only
|
|
hides `.meta` + `.chev` (v-if). The button keeps the SAME padding in
|
|
both states — this is what kills the avatar jump:
|
|
|
|
avatar offset = wrapper px-2 (8px) + trigger p-2 (8px) = 16px
|
|
|
|
16px from the rail's left edge in BOTH collapsed and expanded —
|
|
identical to the SidebarHeader brand logo (px-4). Because the
|
|
horizontal padding is symmetric (8 + 8 each side) and the rail
|
|
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-2">
|
|
<!--
|
|
Trigger — single button for both states. Transparent at rest; grey
|
|
on hover; grey KEPT while the popover is open (isOpen, wired to the
|
|
Popover @show/@hide). The `p-2` gives the grey background generous
|
|
padding around the avatar+text (crewli-starter hover treatment),
|
|
and because the background is the button's own bg (not a separate
|
|
shifting layer) it never moves the avatar.
|
|
-->
|
|
<button
|
|
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="{ 'bg-surface-100 dark:bg-surface-800': isOpen }"
|
|
aria-haspopup="true"
|
|
:aria-expanded="isOpen"
|
|
:aria-label="collapsed && current ? `Workspace: ${current.name}` : undefined"
|
|
@click="toggle"
|
|
>
|
|
<!-- Avatar square — shared treatment with SidebarHeader logo (h-8 w-8 rounded-lg). Gradient background is bespoke per organisation (dynamic hex pair, RFC §7.4 inline-style). -->
|
|
<span
|
|
v-if="current"
|
|
class="ws-logo ws-logo-square w-8 h-8 flex-shrink-0 rounded-lg inline-flex items-center justify-center text-white font-bold text-[12px]"
|
|
:style="{ background: `linear-gradient(135deg, ${current.gradient[0]}, ${current.gradient[1]})` }"
|
|
>
|
|
{{ current.initials }}
|
|
</span>
|
|
|
|
<!-- Meta: name + placeholder sub line (hidden when collapsed). -->
|
|
<span
|
|
v-if="!collapsed && current"
|
|
class="meta flex flex-1 min-w-0 flex-col text-left leading-[1.2]"
|
|
>
|
|
<span class="name truncate text-[13.5px] font-semibold text-[var(--p-text-color)]">
|
|
{{ current.name }}
|
|
</span>
|
|
<span class="sub truncate text-[11.5px] text-[var(--p-text-muted-color)]">
|
|
{{ current.sub }}
|
|
</span>
|
|
</span>
|
|
|
|
<!-- Chevron: hidden when collapsed. -->
|
|
<Icon
|
|
v-if="!collapsed"
|
|
name="tabler-chevron-down"
|
|
:size="14"
|
|
class="chev flex-shrink-0 text-[var(--p-text-muted-color)]"
|
|
/>
|
|
</button>
|
|
|
|
<!-- PrimeVue Popover — replaces crewli-starter's manual document.mousedown click-outside -->
|
|
<Popover
|
|
ref="popoverRef"
|
|
@show="isOpen = true"
|
|
@hide="isOpen = false"
|
|
>
|
|
<!-- Header -->
|
|
<div class="popover-head flex items-center justify-between border-b border-[var(--p-content-border-color)] px-[16px] py-[14px]">
|
|
<span class="title text-[15px] font-bold tracking-[-0.01em]">Workspaces</span>
|
|
<!--
|
|
TODO TECH-WS-GUI-REDESIGN: no v2-workspaces-manage route yet —
|
|
rendered as a non-navigating label per the routeName-undefined
|
|
convention (registry-style). Becomes a RouterLink when the
|
|
manage-workspaces page lands.
|
|
-->
|
|
<span class="link cursor-pointer text-[13px] font-medium text-[var(--p-primary-color)]">Manage</span>
|
|
</div>
|
|
|
|
<!-- List of organisations -->
|
|
<div class="list min-w-[280px] p-[6px]">
|
|
<!-- TODO TECH-WS-GUI-REDESIGN: full listbox/menu ARIA (role, roving tabindex, arrow-key nav) — tracked -->
|
|
<button
|
|
v-for="ws in allOrgs"
|
|
:key="ws.id"
|
|
type="button"
|
|
class="opt grid w-full grid-cols-[36px_1fr_auto] cursor-pointer items-center gap-[12px] rounded-[var(--p-border-radius)] border-0 bg-transparent p-[10px] text-start hover:bg-[var(--p-content-hover-background)]"
|
|
:class="[
|
|
ws.id === current?.id ? 'is-current bg-[var(--p-primary-50)]' : '',
|
|
]"
|
|
:aria-current="current?.id === ws.id ? 'true' : undefined"
|
|
@click="selectOrg(ws)"
|
|
>
|
|
<!-- Org logo (large variant) — dynamic gradient via inline :style -->
|
|
<span
|
|
class="ws-logo lg ws-logo-square-lg w-9 h-9 flex-shrink-0 rounded-[var(--p-border-radius)] inline-flex items-center justify-center text-white font-bold text-[13px]"
|
|
:style="{ background: `linear-gradient(135deg, ${ws.gradient[0]}, ${ws.gradient[1]})` }"
|
|
>{{ ws.initials }}</span>
|
|
|
|
<!--
|
|
Name + placeholder sub line (P6-styling-switcher-sub reverses
|
|
AD-2.5-W1 option A). Real org type + metrics still deferred
|
|
under WORKSPACE-DROPDOWN-SUB-CONTENT.
|
|
-->
|
|
<span class="min-w-0">
|
|
<div class="name truncate text-[14px] font-semibold text-[var(--p-text-color)]">{{ ws.name }}</div>
|
|
<div class="sub mt-[2px] truncate text-[12.5px] text-[var(--p-text-muted-color)]">{{ ws.sub }}</div>
|
|
</span>
|
|
|
|
<!-- Check mark for active org -->
|
|
<Icon
|
|
v-if="ws.id === current?.id"
|
|
name="tabler-check"
|
|
:size="18"
|
|
class="check-mark text-[var(--p-primary-color)]"
|
|
/>
|
|
<!-- Spacer when not current (keeps grid alignment) -->
|
|
<span
|
|
v-else
|
|
class="w-[18px]"
|
|
/>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Footer -->
|
|
<div class="foot flex gap-[4px] border-t border-[var(--p-content-border-color)] p-[8px]">
|
|
<button
|
|
type="button"
|
|
class="inline-flex flex-1 h-[36px] items-center justify-center gap-[6px] rounded-[var(--p-border-radius)] border-0 bg-transparent text-[13px] font-medium text-[var(--p-text-color)] hover:bg-[var(--p-content-hover-background)]"
|
|
@click="createWorkspace"
|
|
>
|
|
<Icon
|
|
name="tabler-plus"
|
|
:size="14"
|
|
/>
|
|
New workspace
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="inline-flex flex-1 h-[36px] items-center justify-center gap-[6px] rounded-[var(--p-border-radius)] border-0 bg-transparent text-[13px] font-medium text-[var(--p-text-color)] hover:bg-[var(--p-content-hover-background)]"
|
|
@click="inviteUser"
|
|
>
|
|
<Icon
|
|
name="tabler-user-plus"
|
|
:size="14"
|
|
/>
|
|
Invite
|
|
</button>
|
|
</div>
|
|
</Popover>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
/**
|
|
* ws-logo-square — width/height moved to Tailwind (w-8 h-8 on the element).
|
|
* Tailwind has no inset directional box-shadow utility at this granularity →
|
|
* scoped CSS last resort per RFC §7.4.
|
|
*/
|
|
.ws-logo-square {
|
|
box-shadow: inset 0 -2px 0 rgb(0 0 0 / 10%);
|
|
}
|
|
|
|
/**
|
|
* ws-logo-square-lg — width/height moved to Tailwind (w-9 h-9 on the element).
|
|
* Same box-shadow exception as above (inset shadow has no Tailwind equivalent).
|
|
* Background gradient set via inline :style on the element (dynamic hex pair).
|
|
*/
|
|
.ws-logo-square-lg {
|
|
box-shadow: inset 0 -2px 0 rgb(0 0 0 / 10%);
|
|
}
|
|
</style>
|