mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-21 18:13:26 +00:00
fix: topbar overflow drag/drop and dashboard flicker (IDEA-758) (#254)
Series of regressions found while testing the workspace topbar overflow menu shipped in IDEA-758 / TASK-759: - Layout collapse: `.workspace-list` had no `flex: 1`, so ResizeObserver fed the shrinking content width back into the fitting calc and ratcheted down to "active pill only". Wrap pills, trigger, and add button in a centered `.workspace-row` that owns `flex: 1`; the row's full width now drives the split. - Trigger position + menu anchoring: trigger now sits next to the last visible pill, and the menu opens directly under the trigger via a `position: relative` `.overflow-anchor` wrapper. - Overflow zone not registering as a drop target: switched from `pointer-events: none` / `transform: scale(0)` to `visibility: hidden` for the closed state. svelte-dnd-action's hit-test uses bounding-rect math (not `elementsFromPoint`), and `scale(0)` confuses its transform-undoing on percentage origins. - Pre-mount the menu DOM on mousedown via `dragArmed` so the dndzone is registered before drag starts (mid-drag mount isn't picked up). - Post-drop snap-back: set `dropCooldown = true` synchronously in finalize handlers, before flipping `isDragging`, so the resync effect doesn't clobber the post-drag zones before the persist microtask runs. - Click-after-drop navigation: `dropClickGuard` swallows the synthetic click that fires on the dragged `<a>` after mouseup, preventing `goto()` from firing on every drop. - Dashboard re-fetch flicker: `workspaceStore.setCurrent`'s synchronous `workspaces.find(...)` was leaking a reactive dep on `workspaceStore.workspaces` into both the workspace `+layout` effect and the dashboard `+page` load effect. Wrap both in `untrack(...)` so they only re-run on `wsSlug` change. - Active-pin reject cleanup: rejection paths now call `clearCooldownAfterRejection()` so a stuck `dropCooldown` from the source-zone finalize doesn't gate sync effects forever. - A11y: `aria-expanded` on the trigger now uses a `menuVisible` derived (`overflowOpen || isDragging || dragArmed`) so it matches the visual open state. - Replace `CHROME_RESERVATION = 72` magic number with named parts derived from the actual CSS box model (= 68, was off by 4).
This commit is contained in:
@@ -41,27 +41,37 @@
|
||||
// We replaced the previous `overflow-x: auto` scroll with a "priority+"
|
||||
// pattern: pills that don't fit move into a `…` overflow menu. To
|
||||
// decide which pills fit, we measure each pill's natural width once in
|
||||
// a hidden ghost row, then walk the ordered list against the visible
|
||||
// container's width. ResizeObserver re-derives on container width
|
||||
// change (window resize, sidebar collapse, etc).
|
||||
let containerEl: HTMLDivElement | null = $state(null);
|
||||
// a hidden ghost row, then walk the ordered list against the centered
|
||||
// row's width. ResizeObserver is on `.workspace-row` (the centered
|
||||
// flex:1 wrapper that holds pills + trigger + add) so we measure the
|
||||
// real available row, not just the content-collapsed pill list. Re-
|
||||
// derives on container width change (window resize, sidebar collapse).
|
||||
let rowEl: HTMLDivElement | null = $state(null);
|
||||
let ghostEl: HTMLDivElement | null = $state(null);
|
||||
let availableWidth = $state(0);
|
||||
let pillWidths = $state(new Map<string, number>());
|
||||
|
||||
// Reserve width for the `…` trigger so the visible/overflow split is
|
||||
// stable — see the rendering note on `.overflow-trigger-wrap.hidden`.
|
||||
const TRIGGER_RESERVATION = 32;
|
||||
const GAP = 2; // matches `.workspace-list` CSS gap
|
||||
// Chrome that shares the centered row with the pills. Broken into
|
||||
// named parts (instead of a single magic 72) so a styling change in
|
||||
// one of the contributing CSS rules has an obvious place to update,
|
||||
// and the math stays auditable. Numbers reflect the current CSS box
|
||||
// model and `var(--space-2)` token (8px); update both together.
|
||||
const TRIGGER_WIDTH = 28; // .overflow-trigger: 24 inner + 2px border × 2
|
||||
const ADD_BUTTON_WIDTH = 28; // .workspace-add: 24 inner + 2px border × 2
|
||||
const ROW_GAP = 2; // .workspace-row gap (between list, anchor, add)
|
||||
const LIST_PADDING_RIGHT = 8; // .workspace-list padding-right (var(--space-2))
|
||||
const CHROME_RESERVATION =
|
||||
TRIGGER_WIDTH + ADD_BUTTON_WIDTH + 2 * ROW_GAP + LIST_PADDING_RIGHT; // = 68
|
||||
const GAP = 2; // matches `.workspace-row` and `.workspace-list` CSS gap
|
||||
|
||||
$effect(() => {
|
||||
if (!containerEl) return;
|
||||
if (!rowEl) return;
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
availableWidth = entry.contentRect.width;
|
||||
}
|
||||
});
|
||||
ro.observe(containerEl);
|
||||
ro.observe(rowEl);
|
||||
return () => ro.disconnect();
|
||||
});
|
||||
|
||||
@@ -86,24 +96,24 @@
|
||||
});
|
||||
|
||||
// ── Visible / overflow split ────────────────────────────────────────
|
||||
// Always reserves trigger width when there's overflow, AND renders the
|
||||
// trigger as `visibility: hidden` even when empty so layout is stable.
|
||||
// This avoids oscillation between "trigger hidden → list grows → all
|
||||
// fit → trigger hidden" and the next frame's "all fit → trigger shown
|
||||
// (?) → ...". Stable layout > shaving 28px when not needed.
|
||||
// Pills compete for `budget = availableWidth - CHROME_RESERVATION`.
|
||||
// Chrome (trigger + add button + gaps) is always reserved, so layout
|
||||
// is stable across overflow / no-overflow transitions — the trigger
|
||||
// itself uses visibility: hidden when there's nothing to overflow.
|
||||
let propVisibleWorkspaces = $derived.by(() => {
|
||||
if (!availableWidth || pillWidths.size === 0) return dndWorkspaces;
|
||||
|
||||
// Total width if we tried to fit everything (no trigger).
|
||||
// Pill space = row width minus the chrome that sits beside the pills.
|
||||
const budget = availableWidth - CHROME_RESERVATION;
|
||||
|
||||
// Total width if we tried to fit everything.
|
||||
let total = 0;
|
||||
for (let i = 0; i < dndWorkspaces.length; i++) {
|
||||
total += pillWidths.get(dndWorkspaces[i].slug) ?? 0;
|
||||
if (i > 0) total += GAP;
|
||||
}
|
||||
if (total <= availableWidth) return dndWorkspaces;
|
||||
if (total <= budget) return dndWorkspaces;
|
||||
|
||||
// Some pills overflow — reserve trigger room.
|
||||
const budget = availableWidth - TRIGGER_RESERVATION - GAP;
|
||||
const activeSlug = workspaceStore.current?.slug ?? '';
|
||||
|
||||
// Pin active: claim its width up front regardless of position.
|
||||
@@ -156,27 +166,95 @@
|
||||
});
|
||||
|
||||
// ── Overflow menu state ──────────────────────────────────────────────
|
||||
// The menu's `.open` class is bound to `overflowOpen || isDragging ||
|
||||
// dragArmed`:
|
||||
// • `overflowOpen` — user clicked the … trigger.
|
||||
// • `isDragging` — drag in progress, auto-show during drag.
|
||||
// • `dragArmed` — mousedown on a draggable pill but drag hasn't
|
||||
// started yet. Pre-arm matters because `svelte-dnd-action` does
|
||||
// its drop-zone hit-testing with `document.elementsFromPoint`,
|
||||
// which excludes elements with `pointer-events: none`. If the
|
||||
// menu is still `pointer-events: none` at the moment the drag
|
||||
// threshold is crossed, the library evaluates the zone as
|
||||
// non-interactive and stops considering it as a hit target for
|
||||
// the rest of this drag — the user can see the menu but drops
|
||||
// and FLIP animations on existing items don't register. Flipping
|
||||
// `.open` synchronously on mousedown sidesteps that race.
|
||||
// The menu DOM itself is mounted whenever `overflowZone.length > 0`,
|
||||
// so the dndzone is registered well before any drag starts.
|
||||
let overflowOpen = $state(false);
|
||||
let dragArmed = $state(false);
|
||||
let overflowTriggerEl: HTMLButtonElement | null = $state(null);
|
||||
let springLoadTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
// Single tracked timer for the post-drop cooldown — re-armed on each
|
||||
// drop so two drops within 1s don't see the first timeout end the
|
||||
// cooldown for the second (Codex round 1 MEDIUM).
|
||||
let cooldownTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const SPRING_LOAD_MS = 400;
|
||||
|
||||
function clearSpringLoad() {
|
||||
if (springLoadTimer) {
|
||||
clearTimeout(springLoadTimer);
|
||||
springLoadTimer = null;
|
||||
}
|
||||
// Drag-end click suppression. After a drop, the browser fires a
|
||||
// synthetic `click` on the original mousedown target (the dragged
|
||||
// `<a>` pill). svelte-dnd-action does NOT cancel that click, so
|
||||
// without this guard the click reaches `handleWsClick`, calls
|
||||
// `goto()`, and navigates to the dragged workspace's restore route
|
||||
// — which the user experiences as the current page refreshing
|
||||
// every time they reorder workspaces. `armDropClickGuard()` is
|
||||
// called at the end of every finalize handler; `handleWsClick`
|
||||
// bails out while the guard is active.
|
||||
let dropClickGuard = false;
|
||||
let dropClickGuardTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
function armDropClickGuard() {
|
||||
dropClickGuard = true;
|
||||
if (dropClickGuardTimer) clearTimeout(dropClickGuardTimer);
|
||||
// 100ms is comfortably longer than the gap between mouseup and
|
||||
// the synthetic click, but short enough that a deliberate click
|
||||
// shortly after a drag isn't accidentally swallowed.
|
||||
dropClickGuardTimer = setTimeout(() => {
|
||||
dropClickGuard = false;
|
||||
dropClickGuardTimer = null;
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function closeOverflow() {
|
||||
overflowOpen = false;
|
||||
clearSpringLoad();
|
||||
}
|
||||
|
||||
function armMenuForDrag(e: MouseEvent) {
|
||||
// Primary button only — secondary clicks shouldn't pre-open.
|
||||
if (e.button !== 0) return;
|
||||
dragArmed = true;
|
||||
}
|
||||
|
||||
function disarmMenu() {
|
||||
// Cleared on mouseup if no drag actually started. If a drag DID
|
||||
// start, `isDragging` is true here and the corresponding finalize
|
||||
// handler clears `dragArmed` itself when the drop completes.
|
||||
if (!isDragging) dragArmed = false;
|
||||
}
|
||||
|
||||
// Roll back any cooldown state set by an earlier (source-zone) finalize
|
||||
// when a later (target-zone) finalize takes the active-pin reject path.
|
||||
// svelte-dnd-action doesn't guarantee finalize ordering across zones, so
|
||||
// the visible-zone finalize may run first, set `dropCooldown = true`,
|
||||
// and `schedulePersist`. If the overflow/trigger finalize then rejects,
|
||||
// `cancelPersist` skips the persist microtask — which means the
|
||||
// cooldown timer (only armed inside `persistGlobalOrder`) is never set,
|
||||
// and `dropCooldown` stays stuck `true`. Both store→local sync effects
|
||||
// are gated on `!dropCooldown`, so a stuck flag detaches the topbar
|
||||
// from `workspaceStore.workspaces` until the next successful drop.
|
||||
function clearCooldownAfterRejection() {
|
||||
dropCooldown = false;
|
||||
if (cooldownTimer) {
|
||||
clearTimeout(cooldownTimer);
|
||||
cooldownTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Visual open state for the overflow menu — used both for the `.open`
|
||||
// CSS class and for `aria-expanded` on the trigger so screen readers
|
||||
// see the same state as sighted users. (The keyboard menu navigation
|
||||
// stays gated on `overflowOpen` only, since drag-driven opens aren't
|
||||
// keyboard-reachable in the first place.)
|
||||
let menuVisible = $derived(overflowOpen || isDragging || dragArmed);
|
||||
|
||||
// ── DnD handlers ─────────────────────────────────────────────────────
|
||||
function handleVisibleConsider(e: CustomEvent<DndEvent<Workspace>>) {
|
||||
visibleZone = e.detail.items;
|
||||
@@ -193,13 +271,23 @@
|
||||
// the order in which the source and target finalize events fire.)
|
||||
if (dragRejected) {
|
||||
dragRejected = false;
|
||||
dragArmed = false;
|
||||
isDragging = false;
|
||||
clearSpringLoad();
|
||||
armDropClickGuard();
|
||||
return;
|
||||
}
|
||||
visibleZone = e.detail.items;
|
||||
// Cooldown MUST be set synchronously, before flipping `isDragging`,
|
||||
// so the resync `$effect` (which runs in a microtask before the
|
||||
// persist microtask) sees `dropCooldown=true` and skips clobbering
|
||||
// `visibleZone` back to the un-updated derived value. Without this
|
||||
// gate, the just-set post-drag order is reset before `persistGlobalOrder`
|
||||
// has a chance to fold it into `dndWorkspaces` — and the API call
|
||||
// then persists the pre-drag order, producing the visual snap-back.
|
||||
dropCooldown = true;
|
||||
dragArmed = false;
|
||||
isDragging = false;
|
||||
clearSpringLoad();
|
||||
armDropClickGuard();
|
||||
schedulePersist();
|
||||
}
|
||||
|
||||
@@ -220,36 +308,35 @@
|
||||
dragRejected = true;
|
||||
resetZonesFromProps();
|
||||
cancelPersist();
|
||||
// If the visible-zone finalize ran first, it set dropCooldown=true
|
||||
// and scheduled a persist. cancelPersist() above stops that persist,
|
||||
// but the cooldown timer was never armed (it's set inside the
|
||||
// cancelled microtask) — clear the cooldown now so sync effects
|
||||
// don't stay gated off forever.
|
||||
clearCooldownAfterRejection();
|
||||
dragArmed = false;
|
||||
isDragging = false;
|
||||
clearSpringLoad();
|
||||
armDropClickGuard();
|
||||
return;
|
||||
}
|
||||
overflowZone = next;
|
||||
// Set cooldown BEFORE isDragging — see handleVisibleFinalize.
|
||||
dropCooldown = true;
|
||||
dragArmed = false;
|
||||
isDragging = false;
|
||||
clearSpringLoad();
|
||||
armDropClickGuard();
|
||||
schedulePersist();
|
||||
}
|
||||
|
||||
// Trigger zone — single-slot drop target. While a drag is hovering,
|
||||
// schedule the spring-load timer to auto-open the menu so the user
|
||||
// can place the dropped item at a precise position. If the user drops
|
||||
// on the trigger before the timer fires, the dropped item is appended
|
||||
// to the end of overflow.
|
||||
// Trigger zone — single-slot drop target. The menu auto-opens on
|
||||
// drag start (see the `.open` class binding on `.overflow-menu`), so
|
||||
// the older spring-loaded "hover for 400ms to open" behavior is no
|
||||
// longer needed. The trigger zone still exists so dropping directly
|
||||
// on the `…` button appends the item to the end of the overflow set.
|
||||
function handleTriggerConsider(e: CustomEvent<DndEvent<Workspace>>) {
|
||||
triggerZone = e.detail.items;
|
||||
isDragging = true;
|
||||
dragRejected = false;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const hovering = triggerZone.some((item: any) => !!item[SHADOW_ITEM_MARKER_PROPERTY_NAME]);
|
||||
if (hovering && !overflowOpen && !springLoadTimer) {
|
||||
springLoadTimer = setTimeout(() => {
|
||||
springLoadTimer = null;
|
||||
overflowOpen = true;
|
||||
}, SPRING_LOAD_MS);
|
||||
} else if (!hovering) {
|
||||
clearSpringLoad();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTriggerFinalize(e: CustomEvent<DndEvent<Workspace>>) {
|
||||
@@ -258,7 +345,6 @@
|
||||
(item: any) => !item[SHADOW_ITEM_MARKER_PROPERTY_NAME]
|
||||
);
|
||||
triggerZone = [];
|
||||
clearSpringLoad();
|
||||
|
||||
// Active-pin under "drop on trigger": REJECT the whole drag if
|
||||
// active was the dropped item, same as the overflow zone. Don't
|
||||
@@ -269,14 +355,24 @@
|
||||
dragRejected = true;
|
||||
resetZonesFromProps();
|
||||
cancelPersist();
|
||||
// See clearCooldownAfterRejection comment in handleOverflowFinalize:
|
||||
// the source-zone finalize may have already set dropCooldown=true,
|
||||
// and the cancelled persist won't arm the cooldown timer.
|
||||
clearCooldownAfterRejection();
|
||||
dragArmed = false;
|
||||
isDragging = false;
|
||||
armDropClickGuard();
|
||||
return;
|
||||
}
|
||||
|
||||
if (dropped.length > 0) {
|
||||
overflowZone = [...overflowZone, ...dropped];
|
||||
}
|
||||
// Set cooldown BEFORE isDragging — see handleVisibleFinalize.
|
||||
dropCooldown = true;
|
||||
dragArmed = false;
|
||||
isDragging = false;
|
||||
armDropClickGuard();
|
||||
schedulePersist();
|
||||
}
|
||||
|
||||
@@ -403,11 +499,14 @@
|
||||
// runtime no-ops the writes, but it's still tidier to cancel.
|
||||
$effect(() => {
|
||||
return () => {
|
||||
clearSpringLoad();
|
||||
if (cooldownTimer) {
|
||||
clearTimeout(cooldownTimer);
|
||||
cooldownTimer = null;
|
||||
}
|
||||
if (dropClickGuardTimer) {
|
||||
clearTimeout(dropClickGuardTimer);
|
||||
dropClickGuardTimer = null;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
@@ -445,6 +544,13 @@
|
||||
// Click on the *current* workspace overrides the restore — gives the
|
||||
// user a way back to the workspace dashboard from any deep route.
|
||||
function handleWsClick(e: MouseEvent, ws: Workspace) {
|
||||
// Suppress the synthetic click that fires after a drag finalizes
|
||||
// (see armDropClickGuard). Without this, every drop triggers a
|
||||
// goto() to the dragged workspace, refreshing the current page.
|
||||
if (dropClickGuard) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
closeOverflow();
|
||||
@@ -482,13 +588,18 @@
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
onmouseup={disarmMenu}
|
||||
onclick={(e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (userMenuOpen && !target.closest('.user-menu-container')) {
|
||||
closeUserMenu();
|
||||
}
|
||||
// Don't close on outside-click during a drag — the click event
|
||||
// fired on mouseup at drag end would otherwise tear down the
|
||||
// menu while finalize handlers are still wiring up state.
|
||||
if (
|
||||
overflowOpen &&
|
||||
!isDragging &&
|
||||
!target.closest('#workspace-overflow-menu') &&
|
||||
!target.closest('.overflow-trigger-wrap')
|
||||
) {
|
||||
@@ -504,111 +615,47 @@
|
||||
<div class="topbar-left">
|
||||
<PadLogo />
|
||||
</div>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="workspace-list"
|
||||
bind:this={containerEl}
|
||||
use:dndzone={{
|
||||
items: visibleZone,
|
||||
flipDurationMs,
|
||||
type: 'topbar-workspace',
|
||||
dragDisabled: uiStore.isTouch
|
||||
}}
|
||||
onconsider={handleVisibleConsider}
|
||||
onfinalize={handleVisibleFinalize}
|
||||
>
|
||||
{#each visibleZone as ws (ws.id)}
|
||||
<!--
|
||||
href stays pointed at the workspace dashboard so a
|
||||
middle-click / cmd-click / right-click → "Open in new
|
||||
tab" lands on a clean dashboard. Plain left-click is
|
||||
intercepted to restore the last-visited route in that
|
||||
workspace (TASK-754) — the previous behavior was a
|
||||
hard `<a>` nav to the dashboard, which silently
|
||||
overwrote the saved deep route in localStorage on
|
||||
every workspace switch.
|
||||
-->
|
||||
<a
|
||||
href="/{ws.owner_username}/{ws.slug}"
|
||||
class="workspace-item"
|
||||
class:active={ws.slug === currentSlug}
|
||||
title={ws.name}
|
||||
onclick={(e) => handleWsClick(e, ws)}
|
||||
>
|
||||
<span
|
||||
class="workspace-icon"
|
||||
style="background: {ws.slug === currentSlug
|
||||
? wsColor(ws.name)
|
||||
: 'transparent'}; color: {ws.slug === currentSlug
|
||||
? '#fff'
|
||||
: 'var(--text-secondary)'}; border-color: {wsColor(ws.name)}"
|
||||
>
|
||||
{wsInitial(ws.name)}
|
||||
</span>
|
||||
<span class="workspace-name">{ws.name}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!--
|
||||
Overflow trigger. Always rendered (so layout stays stable as
|
||||
workspaces are added/removed) but visually hidden when nothing
|
||||
overflows. The wrapper is a 3rd dndzone with the same `type`
|
||||
as the visible row and the menu, so dropping on the trigger
|
||||
appends to overflow. Spring-loaded auto-open on hover-during-
|
||||
drag is implemented in handleTriggerConsider.
|
||||
Centered row that holds the workspace pills, the overflow `…`
|
||||
trigger, and the `+` add button as a single visual group. The
|
||||
row is `flex: 1 1 auto; justify-content: center` so its
|
||||
contents stay centered as the topbar widens or narrows, and
|
||||
ResizeObserver measures THIS element to know how much space
|
||||
pills can claim (see CHROME_RESERVATION above). Putting the
|
||||
trigger + add inside this group is what keeps the `…` button
|
||||
right next to the last visible pill instead of pinned to the
|
||||
far right of the topbar.
|
||||
-->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="overflow-trigger-wrap"
|
||||
class:hidden={overflowZone.length === 0}
|
||||
use:dndzone={{
|
||||
items: triggerZone,
|
||||
flipDurationMs,
|
||||
type: 'topbar-workspace',
|
||||
dragDisabled: uiStore.isTouch || overflowZone.length === 0
|
||||
}}
|
||||
onconsider={handleTriggerConsider}
|
||||
onfinalize={handleTriggerFinalize}
|
||||
>
|
||||
<button
|
||||
class="overflow-trigger"
|
||||
bind:this={overflowTriggerEl}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={overflowOpen}
|
||||
aria-controls="workspace-overflow-menu"
|
||||
aria-label="Show {overflowZone.length} more workspace{overflowZone.length === 1
|
||||
? ''
|
||||
: 's'}"
|
||||
title="More workspaces"
|
||||
onclick={() => (overflowOpen = !overflowOpen)}
|
||||
>
|
||||
<span class="overflow-dots" aria-hidden="true">⋯</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if overflowOpen && overflowZone.length > 0}
|
||||
<div class="workspace-row" bind:this={rowEl}>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
id="workspace-overflow-menu"
|
||||
class="overflow-menu"
|
||||
role="menu"
|
||||
class="workspace-list"
|
||||
use:dndzone={{
|
||||
items: overflowZone,
|
||||
items: visibleZone,
|
||||
flipDurationMs,
|
||||
type: 'topbar-workspace',
|
||||
dragDisabled: uiStore.isTouch
|
||||
}}
|
||||
onconsider={handleOverflowConsider}
|
||||
onfinalize={handleOverflowFinalize}
|
||||
onconsider={handleVisibleConsider}
|
||||
onfinalize={handleVisibleFinalize}
|
||||
>
|
||||
{#each overflowZone as ws (ws.id)}
|
||||
{#each visibleZone as ws (ws.id)}
|
||||
<!--
|
||||
href stays pointed at the workspace dashboard so a
|
||||
middle-click / cmd-click / right-click → "Open in
|
||||
new tab" lands on a clean dashboard. Plain left-
|
||||
click is intercepted to restore the last-visited
|
||||
route in that workspace (TASK-754) — the previous
|
||||
behavior was a hard `<a>` nav to the dashboard,
|
||||
which silently overwrote the saved deep route in
|
||||
localStorage on every workspace switch.
|
||||
-->
|
||||
<a
|
||||
href="/{ws.owner_username}/{ws.slug}"
|
||||
class="overflow-menu-item"
|
||||
class="workspace-item"
|
||||
class:active={ws.slug === currentSlug}
|
||||
role="menuitem"
|
||||
title={ws.name}
|
||||
onmousedown={armMenuForDrag}
|
||||
onclick={(e) => handleWsClick(e, ws)}
|
||||
>
|
||||
<span
|
||||
@@ -625,15 +672,118 @@
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class="workspace-add"
|
||||
onclick={() => uiStore.openCreateWorkspace()}
|
||||
title="New workspace"
|
||||
>
|
||||
<span class="add-icon">+</span>
|
||||
</button>
|
||||
<!--
|
||||
Anchor box: a `position: relative` wrapper around the
|
||||
trigger and its dropdown menu. The menu is `position:
|
||||
absolute; right: 0` relative to this anchor, so it always
|
||||
opens directly under the `…` button instead of being
|
||||
centered against the topbar.
|
||||
-->
|
||||
<div class="overflow-anchor">
|
||||
<!--
|
||||
Overflow trigger. Always rendered (so layout stays
|
||||
stable as workspaces are added/removed) but visually
|
||||
hidden when nothing overflows. The wrapper is a 3rd
|
||||
dndzone with the same `type` as the visible row and
|
||||
the menu, so dropping on the trigger appends to
|
||||
overflow. Spring-loaded auto-open on hover-during-
|
||||
drag is implemented in handleTriggerConsider.
|
||||
-->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="overflow-trigger-wrap"
|
||||
class:hidden={overflowZone.length === 0}
|
||||
use:dndzone={{
|
||||
items: triggerZone,
|
||||
flipDurationMs,
|
||||
type: 'topbar-workspace',
|
||||
dragDisabled: uiStore.isTouch || overflowZone.length === 0
|
||||
}}
|
||||
onconsider={handleTriggerConsider}
|
||||
onfinalize={handleTriggerFinalize}
|
||||
>
|
||||
<button
|
||||
class="overflow-trigger"
|
||||
bind:this={overflowTriggerEl}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={menuVisible}
|
||||
aria-controls="workspace-overflow-menu"
|
||||
aria-label="Show {overflowZone.length} more workspace{overflowZone.length === 1
|
||||
? ''
|
||||
: 's'}"
|
||||
title="More workspaces"
|
||||
onclick={() => (overflowOpen = !overflowOpen)}
|
||||
>
|
||||
<span class="overflow-dots" aria-hidden="true">⋯</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
Always mount the menu DOM whenever overflow exists, so
|
||||
`svelte-dnd-action` can register its dndzone before any
|
||||
drag starts. (Mounting the zone mid-drag — what the old
|
||||
spring-load path did — leaves the in-progress drag
|
||||
unable to recognize the zone as a valid drop target.)
|
||||
Visual show/hide is controlled by the `.open` class:
|
||||
open whenever the user click-opened, a drag is in
|
||||
progress, OR the menu has been pre-armed by mousedown
|
||||
on a draggable pill. Pre-arming flips `pointer-events`
|
||||
to `auto` *before* `svelte-dnd-action`'s drop hit-test
|
||||
runs at drag start — see the dragArmed comment in the
|
||||
script section.
|
||||
-->
|
||||
{#if overflowZone.length > 0}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
id="workspace-overflow-menu"
|
||||
class="overflow-menu"
|
||||
class:open={menuVisible}
|
||||
role="menu"
|
||||
use:dndzone={{
|
||||
items: overflowZone,
|
||||
flipDurationMs,
|
||||
type: 'topbar-workspace',
|
||||
dragDisabled: uiStore.isTouch
|
||||
}}
|
||||
onconsider={handleOverflowConsider}
|
||||
onfinalize={handleOverflowFinalize}
|
||||
>
|
||||
{#each overflowZone as ws (ws.id)}
|
||||
<a
|
||||
href="/{ws.owner_username}/{ws.slug}"
|
||||
class="overflow-menu-item"
|
||||
class:active={ws.slug === currentSlug}
|
||||
role="menuitem"
|
||||
title={ws.name}
|
||||
onmousedown={armMenuForDrag}
|
||||
onclick={(e) => handleWsClick(e, ws)}
|
||||
>
|
||||
<span
|
||||
class="workspace-icon"
|
||||
style="background: {ws.slug === currentSlug
|
||||
? wsColor(ws.name)
|
||||
: 'transparent'}; color: {ws.slug === currentSlug
|
||||
? '#fff'
|
||||
: 'var(--text-secondary)'}; border-color: {wsColor(ws.name)}"
|
||||
>
|
||||
{wsInitial(ws.name)}
|
||||
</span>
|
||||
<span class="workspace-name">{ws.name}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="workspace-add"
|
||||
onclick={() => uiStore.openCreateWorkspace()}
|
||||
title="New workspace"
|
||||
>
|
||||
<span class="add-icon">+</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
Hidden ghost row used to measure each pill's natural width.
|
||||
@@ -845,11 +995,32 @@
|
||||
padding-right: var(--space-3);
|
||||
}
|
||||
|
||||
/*
|
||||
Centered row that wraps the workspace pills, the … overflow
|
||||
trigger, and the + add button. `flex: 1 1 auto` claims the
|
||||
leftover topbar space so ResizeObserver sees the real available
|
||||
width (not the content-collapsed width of the inner pill list —
|
||||
that was the IDEA-758 regression fix). `justify-content: center`
|
||||
keeps the trio visually centered when there's slack, matching the
|
||||
pre-overflow-menu layout. Putting all three in this row is also
|
||||
what keeps the … button right after the last visible pill rather
|
||||
than pinned to the far edge of the topbar.
|
||||
*/
|
||||
.workspace-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1 1 auto;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
Workspace list — pills that fit. No more `overflow-x: auto`; pills
|
||||
that don't fit are routed into the .overflow-menu via
|
||||
propVisibleWorkspaces / propOverflowWorkspaces. See script for the
|
||||
measurement + split logic.
|
||||
measurement + split logic. Content-sized within `.workspace-row`
|
||||
so the … trigger sits immediately after the last visible pill.
|
||||
*/
|
||||
.workspace-list {
|
||||
display: flex;
|
||||
@@ -943,6 +1114,21 @@
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/*
|
||||
Positioning anchor for the overflow trigger + dropdown menu.
|
||||
`position: relative` makes the absolutely-positioned `.overflow-
|
||||
menu` open directly under the `…` button (anchored to the right
|
||||
edge of this box) instead of being centered against the whole
|
||||
topbar. The anchor itself is a flex item inside `.workspace-row`
|
||||
and shrinks to its content (the trigger).
|
||||
*/
|
||||
.overflow-anchor {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
Overflow trigger — visually a sibling of .workspace-list and
|
||||
.workspace-add. Always rendered to keep layout stable; visually
|
||||
@@ -987,13 +1173,41 @@
|
||||
}
|
||||
|
||||
/*
|
||||
Overflow menu — anchored under the trigger. Positioned absolutely
|
||||
from .topbar so it floats over surrounding chrome. Reuses the
|
||||
.user-dropdown look for visual consistency.
|
||||
Overflow menu — anchored under the `…` trigger via `.overflow-
|
||||
anchor`'s `position: relative`. `right: 0` aligns the menu's
|
||||
right edge with the trigger's right edge, so the menu opens
|
||||
below right-aligned (per IDEA-758 spec) and extends leftward
|
||||
toward the pills.
|
||||
|
||||
Mounted whenever `overflowZone.length > 0` so `svelte-dnd-
|
||||
action` registers the dndzone eagerly. Hidden via `visibility:
|
||||
hidden` when not `.open`. Subtle but critical: every other
|
||||
obvious "hide" mechanism breaks svelte-dnd-action's drop-zone
|
||||
hit-test (which uses `getBoundingRectNoTransforms` +
|
||||
`isPointInsideRect`, NOT `elementsFromPoint`):
|
||||
• `display: none` → rect is {0,0,0,0}; never hit.
|
||||
• `pointer-events: none` → no effect on the rect-based
|
||||
hit-test, but sometimes interacts oddly with cursor capture.
|
||||
• `transform: scale(0)` → the library tries to undo
|
||||
the transform mathematically (intersection.js:33-37), but
|
||||
parses transform-origin with plain `parseFloat`, so a
|
||||
percentage origin like `top right` ("100% 0%") is read as
|
||||
"100 pixels", and the reconstructed rect lands far from
|
||||
the visible menu position. The hit-test then always misses.
|
||||
`visibility: hidden` leaves `offsetWidth/Height` and
|
||||
`getBoundingClientRect` untouched, so svelte-dnd-action sees
|
||||
the correct rect at the menu's actual layout position whether
|
||||
the menu is visually shown or not — drops register reliably
|
||||
the moment the cursor enters the rect during a drag. Visibility
|
||||
also blocks pointer events and removes the element from the
|
||||
a11y tree when hidden, so closed-state clicks pass through to
|
||||
page content beneath cleanly. Opacity transitions for the
|
||||
visual fade-in; visibility flips instantly on `.open`.
|
||||
*/
|
||||
.overflow-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
min-width: 220px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
@@ -1006,16 +1220,13 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
animation: dropdown-in 0.12s ease-out;
|
||||
/*
|
||||
Approximate horizontal anchor: place near the trigger which
|
||||
sits between the list and the + button. Trigger is centered in
|
||||
the topbar by flexbox; menu uses translateX to nudge under it.
|
||||
Precise positioning isn't critical — the menu just needs to be
|
||||
obviously associated with the trigger.
|
||||
*/
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s ease-out;
|
||||
}
|
||||
.overflow-menu.open {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
.overflow-menu-item {
|
||||
display: flex;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { onMount, onDestroy, untrack } from 'svelte';
|
||||
import { workspaceStore } from '$lib/stores/workspace.svelte';
|
||||
import { collectionStore } from '$lib/stores/collections.svelte';
|
||||
import { editorStore } from '$lib/stores/editor.svelte';
|
||||
@@ -89,14 +89,26 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize workspace, load collections, and reconnect SSE when workspace changes
|
||||
// Initialize workspace, load collections, and reconnect SSE when the
|
||||
// workspace slug changes. The body is wrapped in `untrack` because
|
||||
// `workspaceStore.setCurrent(wsSlug)` synchronously reads
|
||||
// `workspaces.find(...)`, which would otherwise establish a reactive
|
||||
// dependency on the entire workspaces array. With that dependency,
|
||||
// any reorder via the topbar (which calls `workspaceStore.loadAll()`
|
||||
// after persisting) re-runs this whole effect — re-initializing
|
||||
// the SSE callback and reassigning `workspaceStore.current` to a
|
||||
// fresh object — and the resulting reactivity cascade flickers the
|
||||
// current page. Wrapping in untrack keeps the only tracked dep the
|
||||
// `wsSlug` read in the if-check, matching the comment's intent.
|
||||
$effect(() => {
|
||||
if (wsSlug) {
|
||||
workspaceStore.setCurrent(wsSlug);
|
||||
collectionStore.loadCollections(wsSlug);
|
||||
starredStore.load(wsSlug);
|
||||
syncService.setWorkspace(wsSlug);
|
||||
connectSSE();
|
||||
untrack(() => {
|
||||
workspaceStore.setCurrent(wsSlug);
|
||||
collectionStore.loadCollections(wsSlug);
|
||||
starredStore.load(wsSlug);
|
||||
syncService.setWorkspace(wsSlug);
|
||||
connectSSE();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { onMount, onDestroy, untrack } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { api } from '$lib/api/client';
|
||||
import { workspaceStore } from '$lib/stores/workspace.svelte';
|
||||
@@ -37,8 +37,15 @@
|
||||
if (browser) localStorage.removeItem(`pad-onboarding-dismissed-${wsSlug}`);
|
||||
}
|
||||
|
||||
// `load(wsSlug)` calls `workspaceStore.setCurrent(slug)`, which
|
||||
// SYNCHRONOUSLY reads `workspaces.find(...)` before its first await.
|
||||
// That synchronous read would otherwise establish a reactive dependency
|
||||
// on `workspaceStore.workspaces`, so any reorder via the topbar (which
|
||||
// calls `workspaceStore.loadAll()`) would re-fire this effect and cause
|
||||
// the dashboard to refetch + re-render — a visible flicker. Wrap in
|
||||
// `untrack` so the only tracked dep is `wsSlug` from the if-check.
|
||||
$effect(() => {
|
||||
if (wsSlug) load(wsSlug);
|
||||
if (wsSlug) untrack(() => load(wsSlug));
|
||||
});
|
||||
|
||||
// Workspace home shows only the workspace-level title — clear section/item.
|
||||
|
||||
Reference in New Issue
Block a user