mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 02:26:12 +02:00
fix(ui): keep bottom-left overlays clear of the sidebar
The announcement well is `fixed left-3 bottom-3` and 22rem wide. On desktop that is not the corner of the page — it is the sidebar's footer, where the account menu and the Share feedback button live. A 352px card at left:12px covers the whole width of a 240px sidebar, so an announcement lands on top of those controls, and the feedback button's tooltip (z-50, opening upward) lands back on top of the announcement. The toast viewport has the same anchor and the same collision. The two never appear together — the well hides itself while a toast is up — so they have to move together or they disagree with each other. Rather than bump a z-index, which would only decide who wins a fight neither should be in, the overlays now clear the sidebar: - `SidebarShell` publishes its reserved width on the root. It is a sibling of these overlays, not an ancestor, so a custom property is the shared channel. Reserved width, not panel width: a peeking panel overlays content on purpose and is transient, so the overlays must not slide as the pointer crosses it. - `--overlay-gutter-left` and `--overlay-available-width` derive from it. With no sidebar they are exactly the values these overlays already used, so the mobile drawer and any host outside the app shell are unchanged. - Both overlays take the offset at `md:` and up only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
45586170e1
commit
d42b00939f
6 files changed
+121
-3
No files matched your search
@@ -41,7 +41,10 @@ export function AnnouncementWell({ health }: { health?: HealthStatus }) {
|
||||
const modalOpen = useModalOpen(Boolean(announcement));
|
||||
if (!announcement || modalOpen || (toasts?.length ?? 0) > 0) return null;
|
||||
return (
|
||||
<aside aria-label="Paperclip announcements" className="announcement-well fixed left-3 bottom-(--announcement-mobile-bottom) z-40 w-(--announcement-available-width) max-w-(--announcement-width) max-h-(--announcement-mobile-max-height) overflow-y-auto md:bottom-3 md:max-h-(--announcement-max-height)">
|
||||
// `md:left` clears the desktop sidebar, whose footer holds the account menu
|
||||
// and the Share feedback button. Mobile keeps the viewport edge: its sidebar
|
||||
// is a drawer that reserves no width.
|
||||
<aside aria-label="Paperclip announcements" className="announcement-well fixed left-3 bottom-(--announcement-mobile-bottom) z-40 w-(--announcement-available-width) max-w-(--announcement-width) max-h-(--announcement-mobile-max-height) overflow-y-auto md:left-(--overlay-gutter-left) md:bottom-3 md:max-h-(--announcement-max-height)">
|
||||
<AnnouncementCard key={announcement.id} announcement={announcement} onDismiss={() => dismiss(announcement.id)} />
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -122,6 +122,19 @@ export function SidebarShell({
|
||||
// collapsed (the rail width is a fixed constant, not user-resizable).
|
||||
const canResize = resizable && open && !collapsed;
|
||||
|
||||
// The toast viewport is anchored to the viewport's bottom-left, which is this
|
||||
// panel's footer — the account menu and the Share feedback button. It is a
|
||||
// sibling of the shell rather than a descendant, so the width it has to clear
|
||||
// goes on the root. Reserved width, not panel width: a peeking panel overlays
|
||||
// content deliberately and is transient.
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty("--sidebar-reserved-width", `${reservedWidth}px`);
|
||||
return () => {
|
||||
root.style.removeProperty("--sidebar-reserved-width");
|
||||
};
|
||||
}, [reservedWidth]);
|
||||
|
||||
const reservedStyle = useMemo(
|
||||
() => ({ width: `${reservedWidth}px` }),
|
||||
[reservedWidth],
|
||||
|
||||
@@ -65,6 +65,73 @@ describe("SidebarShell", () => {
|
||||
expect(handle()?.getAttribute("aria-valuenow")).toBe("320");
|
||||
});
|
||||
|
||||
// The bottom-left overlays — the announcement well and the toast viewport —
|
||||
// are fixed to the viewport, so without this width they land on the sidebar
|
||||
// footer, on top of the account menu and the Share feedback button.
|
||||
function reservedWidthVariable() {
|
||||
return document.documentElement.style.getPropertyValue("--sidebar-reserved-width");
|
||||
}
|
||||
|
||||
it("publishes the width the bottom-left overlays have to clear", () => {
|
||||
window.localStorage.setItem("test.sidebar.width", "320");
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<SidebarShell open resizable storageKey="test.sidebar.width">
|
||||
<div>Sidebar</div>
|
||||
</SidebarShell>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(reservedWidthVariable()).toBe("320px");
|
||||
|
||||
const separator = handle()!;
|
||||
separator.setPointerCapture = vi.fn();
|
||||
act(() => {
|
||||
separator.dispatchEvent(pointerEvent("pointerdown", 320));
|
||||
separator.dispatchEvent(pointerEvent("pointermove", 260));
|
||||
separator.dispatchEvent(pointerEvent("pointerup", 260));
|
||||
});
|
||||
|
||||
// It tracks a drag, so the overlays do not hang over a narrowed sidebar.
|
||||
expect(reservedWidthVariable()).toBe("260px");
|
||||
});
|
||||
|
||||
it("publishes only the rail width when collapsed, and none when closed", () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<SidebarShell open collapsed storageKey="test.sidebar.width">
|
||||
<div>Sidebar</div>
|
||||
</SidebarShell>,
|
||||
);
|
||||
});
|
||||
expect(reservedWidthVariable()).toBe(`${SIDEBAR_RAIL_WIDTH}px`);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<SidebarShell open={false} storageKey="test.sidebar.width">
|
||||
<div>Sidebar</div>
|
||||
</SidebarShell>,
|
||||
);
|
||||
});
|
||||
// Nothing reserved means the overlays return to the viewport edge.
|
||||
expect(reservedWidthVariable()).toBe("0px");
|
||||
});
|
||||
|
||||
it("reserves nothing to clear while the panel is only peeking", () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<SidebarShell open collapsed peeking storageKey="test.sidebar.width">
|
||||
<div>Sidebar</div>
|
||||
</SidebarShell>,
|
||||
);
|
||||
});
|
||||
|
||||
// A peeking panel overlays content on purpose and is transient, so the
|
||||
// overlays must not jump sideways as the pointer crosses the rail.
|
||||
expect(reservedWidthVariable()).toBe(`${SIDEBAR_RAIL_WIDTH}px`);
|
||||
});
|
||||
|
||||
it("resizes by dragging and persists the new width", () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
|
||||
@@ -122,6 +122,19 @@ export function SidebarShell({
|
||||
// collapsed (the rail width is a fixed constant, not user-resizable).
|
||||
const canResize = resizable && open && !collapsed;
|
||||
|
||||
// The announcement well and the toast viewport are anchored to the viewport's
|
||||
// bottom-left, which is this panel's footer — the account menu and the Share
|
||||
// feedback button. They are siblings of the shell rather than descendants, so
|
||||
// the width they have to clear goes on the root. Reserved width, not panel
|
||||
// width: a peeking panel overlays content deliberately and is transient.
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty("--sidebar-reserved-width", `${reservedWidth}px`);
|
||||
return () => {
|
||||
root.style.removeProperty("--sidebar-reserved-width");
|
||||
};
|
||||
}, [reservedWidth]);
|
||||
|
||||
const reservedStyle = useMemo(
|
||||
() => ({ width: `${reservedWidth}px` }),
|
||||
[reservedWidth],
|
||||
|
||||
@@ -101,7 +101,9 @@ export function ToastViewport() {
|
||||
<aside
|
||||
aria-live="polite"
|
||||
aria-atomic="false"
|
||||
className="pointer-events-none fixed bottom-3 left-3 z-(--z-120) w-full max-w-sm px-1"
|
||||
// Same bottom-left slot as the announcement well, so it clears the
|
||||
// desktop sidebar the same way. Mobile keeps the viewport edge.
|
||||
className="pointer-events-none fixed bottom-3 left-3 z-(--z-120) w-full max-w-sm px-1 md:left-(--overlay-gutter-left) md:w-(--overlay-available-width)"
|
||||
>
|
||||
<ol className="flex w-full flex-col-reverse gap-2">
|
||||
{toasts.map((toast) => (
|
||||
|
||||
+21
-1
@@ -3029,8 +3029,28 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
|
||||
}
|
||||
}
|
||||
:root {
|
||||
/*
|
||||
* Width the desktop sidebar currently reserves. SidebarShell publishes the
|
||||
* live value, including while the panel is being dragged; the fallback of 0
|
||||
* covers every surface without one — the mobile drawer, and any host that
|
||||
* renders the overlays outside the app shell.
|
||||
*/
|
||||
--sidebar-reserved-width: 0px;
|
||||
/*
|
||||
* Left edge for the viewport-anchored overlays at the bottom of the screen:
|
||||
* the announcement well and the toast viewport. They are fixed, so without
|
||||
* this they land on top of the sidebar footer, which is where the account
|
||||
* menu and the Share feedback button live.
|
||||
*/
|
||||
--overlay-gutter-left: calc(var(--sidebar-reserved-width) + var(--spacing) * 3);
|
||||
/*
|
||||
* Width still on screen after the gutter, so a widened sidebar on a narrow
|
||||
* window shortens the overlay instead of pushing it off the right edge.
|
||||
* With no sidebar this is the full-bleed width these overlays always had.
|
||||
*/
|
||||
--overlay-available-width: calc(100vw - var(--overlay-gutter-left) - var(--spacing) * 3);
|
||||
--announcement-width: 22rem;
|
||||
--announcement-available-width: calc(100vw - var(--spacing) * 6);
|
||||
--announcement-available-width: var(--overlay-available-width);
|
||||
--announcement-image-height: 8.5rem;
|
||||
--announcement-image-mobile-height: 6rem;
|
||||
--announcement-mobile-bottom: calc(var(--spacing) * 19 + env(safe-area-inset-bottom, 0px));
|
||||
|
||||
Reference in new issue
Block a user