Refine sidebar behavior and borders (#869)

This commit is contained in:
klopez4212
2026-06-05 14:00:37 -07:00
committed by GitHub
parent 5d7c748969
commit 0a4783c6f8
20 changed files with 458 additions and 50 deletions
+1
View File
@@ -48,6 +48,7 @@ export default defineConfig({
"**/stream.spec.ts",
"**/integration.spec.ts",
"**/profile.spec.ts",
"**/sidebar.spec.ts",
"**/tokens.spec.ts",
"**/persona-env-vars.spec.ts",
"**/mesh-compute.spec.ts",
+1
View File
@@ -8678,6 +8678,7 @@ dependencies = [
"mesh-llm-sdk",
"neteq",
"nostr",
"objc2-app-kit",
"opus",
"png 0.18.1",
"regex",
+3
View File
@@ -27,6 +27,9 @@ tauri-build = { version = "2", features = [] }
libc = "0.2"
ctrlc = { version = "3", features = ["termination"] }
[target.'cfg(target_os = "macos")'.dependencies]
objc2-app-kit = { version = "0.3.2", default-features = false, features = ["NSHapticFeedback"] }
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] }
+17
View File
@@ -335,6 +335,22 @@ fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) {
}
}
#[tauri::command]
fn perform_sidebar_default_haptic() {
#[cfg(target_os = "macos")]
{
use objc2_app_kit::{
NSHapticFeedbackManager, NSHapticFeedbackPattern, NSHapticFeedbackPerformanceTime,
NSHapticFeedbackPerformer,
};
NSHapticFeedbackManager::defaultPerformer().performFeedbackPattern_performanceTime(
NSHapticFeedbackPattern::Alignment,
NSHapticFeedbackPerformanceTime::Now,
);
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let builder = tauri::Builder::default()
@@ -716,6 +732,7 @@ pub fn run() {
add_agent_to_huddle,
check_pipeline_hotstart,
confirm_huddle_active,
perform_sidebar_default_haptic,
get_huddle_agent_pubkeys,
set_voice_input_mode,
get_voice_input_mode,
+4 -1
View File
@@ -684,7 +684,10 @@ export function AppShell() {
>
<HuddleProvider>
<div className="flex h-dvh flex-col overflow-hidden overscroll-none">
<SidebarProvider className="min-h-0 flex-1 overflow-hidden">
<SidebarProvider
className="min-h-0 flex-1 overflow-hidden"
disableRail={settingsOpen}
>
<AppTopChrome
canGoBack={canGoBack}
canGoForward={canGoForward}
+8 -5
View File
@@ -29,6 +29,9 @@ function GlobalTopDivider() {
);
}
const TOP_CHROME_ICON_BUTTON_CLASS =
"size-6 rounded-[4px] p-1 text-muted-foreground/70 hover:bg-border/45 hover:text-foreground";
export function AppTopChrome({
canGoBack,
canGoForward,
@@ -49,28 +52,28 @@ export function AppTopChrome({
/>
<GlobalTopDivider />
<div className="fixed left-[80px] top-[9px] z-[45] flex items-center gap-0.5">
<SidebarTrigger className="h-[22px] w-[22px] text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground" />
<SidebarTrigger className={TOP_CHROME_ICON_BUTTON_CLASS} />
<Button
aria-label="Go back"
className="h-[22px] w-[22px] text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground"
className={TOP_CHROME_ICON_BUTTON_CLASS}
data-testid="global-back"
disabled={!canGoBack}
onClick={onGoBack}
size="icon"
variant="ghost"
>
<ChevronLeft className="h-3 w-3" />
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
aria-label="Go forward"
className="h-[22px] w-[22px] text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground"
className={TOP_CHROME_ICON_BUTTON_CLASS}
data-testid="global-forward"
disabled={!canGoForward}
onClick={onGoForward}
size="icon"
variant="ghost"
>
<ChevronRight className="h-3 w-3" />
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<TopbarSearch
@@ -358,6 +358,7 @@ export function HomeView({
markItemRead(itemId);
}}
selectedId={selectedItemId}
showRightDivider={showListPane && showDetailPane}
/>
) : null}
@@ -33,6 +33,7 @@ type InboxListPaneProps = {
onFilterChange: (filter: InboxFilter) => void;
onSelect: (itemId: string) => void;
selectedId: string | null;
showRightDivider?: boolean;
};
export function InboxListPane({
@@ -42,11 +43,18 @@ export function InboxListPane({
onFilterChange,
onSelect,
selectedId,
showRightDivider = false,
}: InboxListPaneProps) {
const activeFilter = FILTER_OPTIONS.find((option) => option.value === filter);
return (
<section className="relative flex min-h-0 min-w-0 flex-col overflow-hidden bg-background/60">
<section
className={cn(
"relative flex min-h-0 min-w-0 flex-col overflow-hidden bg-background/60",
showRightDivider &&
"after:pointer-events-none after:absolute after:inset-y-0 after:right-0 after:z-40 after:w-px after:bg-border/35 after:content-['']",
)}
>
<TopChromeBackdrop className="h-[76px]" />
<div className="absolute inset-x-0 top-[42px] z-40 min-h-[32px] px-5 py-[4px]">
<div className="flex min-w-0 items-center justify-between gap-3">
@@ -60,6 +60,7 @@ import {
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarRail,
} from "@/shared/ui/sidebar";
type CollapsibleSidebarGroup =
@@ -438,7 +439,7 @@ export function AppSidebar({
</SidebarMenuButton>
{homeBadgeCount > 0 ? (
<SidebarMenuBadge
className="right-2 rounded-full bg-primary/15 px-1.5 text-[11px] text-primary peer-data-[active=true]/menu-button:bg-sidebar-primary-foreground/20 peer-data-[active=true]/menu-button:text-sidebar-primary-foreground"
className="right-2 rounded-full bg-primary/15 px-1.5 text-[11px] text-primary peer-data-[active=true]/menu-button:bg-sidebar-active-foreground/20 peer-data-[active=true]/menu-button:text-sidebar-active-foreground"
data-testid="sidebar-home-count"
>
{Math.min(homeBadgeCount, 99)}
@@ -482,7 +483,7 @@ export function AppSidebar({
</SidebarMenuButton>
{shouldShowAgentCount ? (
<SidebarMenuBadge
className="right-2 rounded-full bg-sidebar-accent/70 px-1.5 text-[11px] text-sidebar-foreground/75 peer-data-[active=true]/menu-button:bg-sidebar-primary-foreground/20 peer-data-[active=true]/menu-button:text-sidebar-primary-foreground"
className="right-2 rounded-full bg-sidebar-accent/70 px-1.5 text-[11px] text-sidebar-foreground/75 peer-data-[active=true]/menu-button:bg-sidebar-active-foreground/20 peer-data-[active=true]/menu-button:text-sidebar-active-foreground"
data-testid="sidebar-agents-count"
>
{totalAgentCount}
@@ -678,7 +679,7 @@ export function AppSidebar({
aria-expanded={isNewDmOpen}
aria-label="Start a direct message"
className={cn(
"top-1/2 -translate-y-1/2 text-sidebar-foreground/50 hover:bg-sidebar-accent/60 hover:text-sidebar-foreground",
"top-1/2 -translate-y-1/2 text-sidebar-foreground/50 hover:bg-sidebar-border/35 hover:text-sidebar-foreground",
SECTION_ACTION_VISIBILITY_CLASS,
)}
data-testid="new-dm-trigger"
@@ -832,6 +833,7 @@ export function AppSidebar({
setDeleteSectionTarget(null);
}}
/>
<SidebarRail />
</Sidebar>
);
}
@@ -50,7 +50,7 @@ import { cn } from "@/shared/lib/cn";
// ---------------------------------------------------------------------------
const SECTION_ICON_BUTTON_CLASS =
"flex h-5 w-5 items-center justify-center rounded-md text-sidebar-foreground/50 hover:bg-sidebar-accent/60 hover:text-sidebar-foreground";
"flex size-6 items-center justify-center rounded-[4px] p-1 text-sidebar-foreground/50 transition-colors hover:bg-sidebar-border/35 hover:text-sidebar-foreground focus-visible:bg-sidebar-border/35 focus-visible:text-sidebar-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-sidebar-ring [&>svg]:size-4 [&>svg]:shrink-0";
export const SECTION_ACTION_VISIBILITY_CLASS =
"opacity-0 transition-opacity group-hover/sidebar-section:opacity-100 group-focus-within/sidebar-section:opacity-100";
const SECTION_LABEL_BUTTON_CLASS =
@@ -271,7 +271,7 @@ function SectionHeaderActions({
onClick={onBrowse}
type="button"
>
<Search className="h-3.5 w-3.5" />
<Search className="h-4 w-4" />
</button>
) : null}
{onCreateClick ? (
@@ -198,7 +198,7 @@ export function ChannelMenuButton({
className={cn(
"ml-auto h-3 w-3 shrink-0",
isActive
? "text-sidebar-primary-foreground/60"
? "text-sidebar-active-foreground/60"
: "text-sidebar-foreground/40",
)}
/>
+9
View File
@@ -0,0 +1,9 @@
import { invoke, isTauri } from "@tauri-apps/api/core";
export function performSidebarDefaultHaptic() {
if (!isTauri()) {
return;
}
void invoke("perform_sidebar_default_haptic").catch(() => {});
}
+4
View File
@@ -243,6 +243,8 @@
--sidebar-foreground: 234 16.02% 35.49%;
--sidebar-primary: 266 85.05% 58.04%;
--sidebar-primary-foreground: 220 23.08% 94.9%;
--sidebar-active: var(--sidebar-primary);
--sidebar-active-foreground: var(--sidebar-primary-foreground);
--sidebar-accent: 220 23.08% 94.9%;
--sidebar-accent-foreground: 234 16.02% 35.49%;
--sidebar-border: 225 13.56% 76.86%;
@@ -281,6 +283,8 @@
--sidebar-foreground: 227 68.25% 87.65%;
--sidebar-primary: 267 82.69% 79.61%;
--sidebar-primary-foreground: 232 23.4% 18.43%;
--sidebar-active: var(--sidebar-primary);
--sidebar-active-foreground: var(--sidebar-primary-foreground);
--sidebar-accent: 232 23.4% 18.43%;
--sidebar-accent-foreground: 227 68.25% 87.65%;
--sidebar-border: 231 15.61% 33.92%;
@@ -87,6 +87,8 @@ function applyAccentColor(value: string) {
root.style.setProperty("--primary-foreground", background);
root.style.setProperty("--sidebar-primary", foreground);
root.style.setProperty("--sidebar-primary-foreground", background);
root.style.setProperty("--sidebar-active", foreground);
root.style.setProperty("--sidebar-active-foreground", background);
return;
}
@@ -97,6 +99,8 @@ function applyAccentColor(value: string) {
root.style.setProperty("--primary-foreground", fgHsl);
root.style.setProperty("--sidebar-primary", accentHsl);
root.style.setProperty("--sidebar-primary-foreground", fgHsl);
root.style.setProperty("--sidebar-active", accentHsl);
root.style.setProperty("--sidebar-active-foreground", fgHsl);
}
/** Apply cached CSS vars synchronously to prevent FOUC. */
+1 -1
View File
@@ -18,7 +18,7 @@ export function TopChromeBackdrop({ className }: { className?: string }) {
<div
aria-hidden="true"
className={cn(
"pointer-events-none absolute inset-x-0 top-0 z-40 h-10 bg-background/75 backdrop-blur-md supports-[backdrop-filter]:bg-background/65 dark:bg-background/45 dark:backdrop-blur-xl dark:supports-[backdrop-filter]:bg-background/35",
"pointer-events-none absolute inset-x-0 top-0 z-40 h-10 bg-background/75 backdrop-blur-md supports-[backdrop-filter]:bg-background/65 after:absolute after:inset-x-0 after:top-10 after:h-px after:bg-border/35 after:content-[''] dark:bg-background/45 dark:backdrop-blur-xl dark:supports-[backdrop-filter]:bg-background/35",
className,
)}
/>
+304 -35
View File
@@ -4,6 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { PanelLeftClose, PanelLeftOpen } from "lucide-react";
import { cn } from "@/shared/lib/cn";
import { performSidebarDefaultHaptic } from "@/shared/lib/haptics";
import { hasPrimaryShortcutModifier } from "@/shared/lib/platform";
import { useIsMobile } from "@/shared/hooks/use-mobile";
import { Button } from "@/shared/ui/button";
@@ -26,7 +27,13 @@ import {
const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "256px";
const SIDEBAR_WIDTH_STORAGE_KEY = "sprout-sidebar-width";
const SIDEBAR_WIDTH_DEFAULT = 300;
const SIDEBAR_WIDTH_DEFAULT_HAPTIC_THRESHOLD = 2;
const SIDEBAR_WIDTH_DEFAULT_SNAP_DISTANCE = 8;
const SIDEBAR_WIDTH_DEFAULT_MAGNET_DISTANCE = 28;
const SIDEBAR_WIDTH_MIN = 220;
const SIDEBAR_WIDTH_MAX = 420;
const SIDEBAR_WIDTH_MOBILE = "288px";
const SIDEBAR_WIDTH_ICON = "48px";
const SIDEBAR_KEYBOARD_SHORTCUT = "s";
@@ -38,6 +45,11 @@ type SidebarContextProps = {
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
isRailDisabled: boolean;
isResizing: boolean;
setIsResizing: (isResizing: boolean) => void;
sidebarWidth: number;
setSidebarWidth: (width: number | ((width: number) => number)) => void;
toggleSidebar: () => void;
};
@@ -52,10 +64,77 @@ function useSidebar() {
return context;
}
function clampSidebarWidth(width: number) {
return Math.min(
SIDEBAR_WIDTH_MAX,
Math.max(SIDEBAR_WIDTH_MIN, Math.round(width)),
);
}
function isSidebarWidthNearDefault(width: number) {
return (
Math.abs(width - SIDEBAR_WIDTH_DEFAULT) <=
SIDEBAR_WIDTH_DEFAULT_HAPTIC_THRESHOLD
);
}
function magnetizeSidebarWidth(width: number) {
const offset = width - SIDEBAR_WIDTH_DEFAULT;
const distance = Math.abs(offset);
if (distance <= SIDEBAR_WIDTH_DEFAULT_SNAP_DISTANCE) {
return SIDEBAR_WIDTH_DEFAULT;
}
if (distance >= SIDEBAR_WIDTH_DEFAULT_MAGNET_DISTANCE) {
return clampSidebarWidth(width);
}
// Ease out of the detent so 300px feels sticky without blocking resize.
const progress =
(distance - SIDEBAR_WIDTH_DEFAULT_SNAP_DISTANCE) /
(SIDEBAR_WIDTH_DEFAULT_MAGNET_DISTANCE -
SIDEBAR_WIDTH_DEFAULT_SNAP_DISTANCE);
const easedDistance =
SIDEBAR_WIDTH_DEFAULT_MAGNET_DISTANCE * progress * progress;
return clampSidebarWidth(
SIDEBAR_WIDTH_DEFAULT + Math.sign(offset) * easedDistance,
);
}
function hasReachedSidebarDefaultWidth(
previousWidth: number,
nextWidth: number,
) {
return (
isSidebarWidthNearDefault(nextWidth) ||
(previousWidth < SIDEBAR_WIDTH_DEFAULT &&
nextWidth > SIDEBAR_WIDTH_DEFAULT) ||
(previousWidth > SIDEBAR_WIDTH_DEFAULT && nextWidth < SIDEBAR_WIDTH_DEFAULT)
);
}
function readSidebarWidth() {
if (typeof window === "undefined") {
return SIDEBAR_WIDTH_DEFAULT;
}
const storedWidth = Number.parseInt(
window.localStorage.getItem(SIDEBAR_WIDTH_STORAGE_KEY) ?? "",
10,
);
return Number.isFinite(storedWidth)
? clampSidebarWidth(storedWidth)
: SIDEBAR_WIDTH_DEFAULT;
}
const SidebarProvider = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
defaultOpen?: boolean;
disableRail?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
@@ -63,6 +142,7 @@ const SidebarProvider = React.forwardRef<
(
{
defaultOpen = true,
disableRail = false,
open: openProp,
onOpenChange: setOpenProp,
className,
@@ -74,6 +154,9 @@ const SidebarProvider = React.forwardRef<
) => {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
const [isResizing, setIsResizing] = React.useState(false);
const [sidebarWidth, setSidebarWidthState] =
React.useState(readSidebarWidth);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
@@ -94,6 +177,26 @@ const SidebarProvider = React.forwardRef<
[setOpenProp, open],
);
const setSidebarWidth = React.useCallback(
(value: number | ((width: number) => number)) => {
setSidebarWidthState((currentWidth) => {
const nextWidth = clampSidebarWidth(
typeof value === "function" ? value(currentWidth) : value,
);
if (typeof window !== "undefined") {
window.localStorage.setItem(
SIDEBAR_WIDTH_STORAGE_KEY,
String(nextWidth),
);
}
return nextWidth;
});
},
[],
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile
@@ -127,11 +230,27 @@ const SidebarProvider = React.forwardRef<
open,
setOpen,
isMobile,
isRailDisabled: disableRail,
isResizing,
setIsResizing,
sidebarWidth,
setSidebarWidth,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, toggleSidebar],
[
state,
open,
setOpen,
isMobile,
disableRail,
isResizing,
sidebarWidth,
setSidebarWidth,
openMobile,
toggleSidebar,
],
);
return (
@@ -140,7 +259,7 @@ const SidebarProvider = React.forwardRef<
<div
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width": `${sidebarWidth}px`,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
@@ -180,7 +299,8 @@ const Sidebar = React.forwardRef<
},
ref,
) => {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
const { isMobile, isResizing, state, openMobile, setOpenMobile } =
useSidebar();
if (collapsible === "none") {
return (
@@ -227,6 +347,7 @@ const Sidebar = React.forwardRef<
className="group peer relative hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-resizing={isResizing}
data-variant={variant}
data-side={side}
>
@@ -234,6 +355,7 @@ const Sidebar = React.forwardRef<
<div
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[resizing=true]:transition-none",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
@@ -244,6 +366,7 @@ const Sidebar = React.forwardRef<
<div
className={cn(
"absolute inset-y-0 z-10 hidden h-full w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
"group-data-[resizing=true]:transition-none",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
@@ -257,7 +380,7 @@ const Sidebar = React.forwardRef<
>
<div
data-sidebar="sidebar"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=sidebar]:border-r group-data-[variant=sidebar]:border-sidebar-border group-data-[variant=sidebar]:pr-px group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
>
{children}
</div>
@@ -297,30 +420,176 @@ SidebarTrigger.displayName = "SidebarTrigger";
const SidebarRail = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button">
>(({ className, ...props }, ref) => {
const { toggleSidebar } = useSidebar();
>(
(
{
className,
onClick,
onPointerCancel,
onPointerDown,
onPointerMove,
onPointerUp,
...props
},
ref,
) => {
const {
setIsResizing,
setSidebarWidth,
isRailDisabled,
sidebarWidth,
state,
toggleSidebar,
} = useSidebar();
const resizeStateRef = React.useRef<{
currentWidth: number;
hasDragged: boolean;
hasReachedDefaultWidth: boolean;
pointerId: number;
previousCursor: string;
previousUserSelect: string;
side: "left" | "right";
startWidth: number;
startX: number;
} | null>(null);
const suppressClickRef = React.useRef(false);
return (
<button
ref={ref}
data-sidebar="rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:hover:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className,
)}
{...props}
/>
);
});
const finishResize = React.useCallback(
(event: React.PointerEvent<HTMLButtonElement>) => {
const resizeState = resizeStateRef.current;
if (!resizeState || resizeState.pointerId !== event.pointerId) {
return;
}
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
document.documentElement.style.cursor = resizeState.previousCursor;
document.body.style.userSelect = resizeState.previousUserSelect;
setIsResizing(false);
resizeStateRef.current = null;
if (resizeState.hasDragged) {
suppressClickRef.current = true;
window.requestAnimationFrame(() => {
suppressClickRef.current = false;
});
}
},
[setIsResizing],
);
return (
<button
ref={ref}
data-sidebar="rail"
aria-label="Resize or toggle sidebar"
tabIndex={-1}
disabled={isRailDisabled}
onClick={(event) => {
if (isRailDisabled) {
return;
}
if (suppressClickRef.current) {
event.preventDefault();
event.stopPropagation();
return;
}
onClick?.(event);
if (!event.defaultPrevented) {
toggleSidebar();
}
}}
onPointerCancel={(event) => {
onPointerCancel?.(event);
finishResize(event);
}}
onPointerDown={(event) => {
onPointerDown?.(event);
if (
isRailDisabled ||
event.defaultPrevented ||
event.button !== 0 ||
state !== "expanded"
) {
return;
}
const side =
event.currentTarget.closest("[data-side='right']") !== null
? "right"
: "left";
resizeStateRef.current = {
currentWidth: sidebarWidth,
hasDragged: false,
hasReachedDefaultWidth: isSidebarWidthNearDefault(sidebarWidth),
pointerId: event.pointerId,
previousCursor: document.documentElement.style.cursor,
previousUserSelect: document.body.style.userSelect,
side,
startWidth: sidebarWidth,
startX: event.clientX,
};
event.currentTarget.setPointerCapture(event.pointerId);
document.documentElement.style.cursor = "col-resize";
document.body.style.userSelect = "none";
setIsResizing(true);
}}
onPointerMove={(event) => {
onPointerMove?.(event);
const resizeState = resizeStateRef.current;
if (!resizeState || resizeState.pointerId !== event.pointerId) {
return;
}
const rawDelta = event.clientX - resizeState.startX;
const delta = resizeState.side === "left" ? rawDelta : -rawDelta;
if (!resizeState.hasDragged && Math.abs(delta) < 3) {
return;
}
resizeState.hasDragged = true;
event.preventDefault();
const nextWidth = magnetizeSidebarWidth(
resizeState.startWidth + delta,
);
const reachedDefaultWidth = hasReachedSidebarDefaultWidth(
resizeState.currentWidth,
nextWidth,
);
if (reachedDefaultWidth && !resizeState.hasReachedDefaultWidth) {
performSidebarDefaultHaptic();
}
resizeState.hasReachedDefaultWidth = reachedDefaultWidth;
resizeState.currentWidth = nextWidth;
setSidebarWidth(nextWidth);
}}
onPointerUp={(event) => {
onPointerUp?.(event);
finishResize(event);
}}
title="Drag to resize or click to toggle sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
"cursor-col-resize",
"after:absolute after:inset-y-0 after:left-1/2 after:z-10 after:w-px after:-translate-x-1/2 after:bg-sidebar-border after:opacity-0 after:transition-opacity after:content-[''] hover:after:opacity-100 focus-visible:after:opacity-100 group-data-[resizing=true]:after:opacity-100",
"[[data-state=collapsed]_&]:cursor-pointer",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:hover:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
"disabled:pointer-events-none disabled:cursor-default",
className,
)}
{...props}
/>
);
},
);
SidebarRail.displayName = "SidebarRail";
const SidebarInset = React.forwardRef<
@@ -469,7 +738,7 @@ const SidebarGroupAction = React.forwardRef<
ref={ref}
data-sidebar="group-action"
className={cn(
"absolute right-3 top-3.5 z-10 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"absolute right-3 top-3.5 z-10 flex size-6 items-center justify-center rounded-[4px] p-1 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-colors hover:bg-sidebar-border/35 hover:text-sidebar-foreground focus-visible:bg-sidebar-border/35 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"group-data-[collapsible=icon]:hidden",
@@ -501,7 +770,7 @@ const SidebarMenu = React.forwardRef<
<ul
ref={ref}
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-0", className)}
className={cn("flex w-full min-w-0 flex-col gap-0.5", className)}
{...props}
/>
));
@@ -521,7 +790,7 @@ const SidebarMenuItem = React.forwardRef<
SidebarMenuItem.displayName = "SidebarMenuItem";
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-primary data-[active=true]:font-semibold data-[active=true]:text-sidebar-primary-foreground data-[active=true]:shadow-xs data-[active=true]:hover:bg-sidebar-primary data-[active=true]:hover:text-sidebar-primary-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-active data-[active=true]:font-semibold data-[active=true]:text-sidebar-active-foreground data-[active=true]:shadow-xs data-[active=true]:hover:bg-sidebar-active data-[active=true]:hover:text-sidebar-active-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
@@ -623,7 +892,7 @@ const SidebarMenuAction = React.forwardRef<
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-primary-foreground peer-data-[active=true]/menu-button:hover:text-sidebar-accent-foreground md:opacity-0",
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-active-foreground peer-data-[active=true]/menu-button:hover:text-sidebar-active-foreground md:opacity-0",
className,
)}
{...props}
@@ -641,7 +910,7 @@ const SidebarMenuBadge = React.forwardRef<
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-primary-foreground",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-active-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
@@ -731,8 +1000,8 @@ const SidebarMenuSubButton = React.forwardRef<
data-size={size}
data-active={isActive}
className={cn(
"flex h-7 min-w-0 items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-hidden ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground data-[active=true]:[&>svg]:text-sidebar-primary-foreground",
"data-[active=true]:bg-sidebar-primary data-[active=true]:font-semibold data-[active=true]:text-sidebar-primary-foreground data-[active=true]:hover:bg-sidebar-primary data-[active=true]:hover:text-sidebar-primary-foreground",
"flex h-7 min-w-0 items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-hidden ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground data-[active=true]:[&>svg]:text-sidebar-active-foreground",
"data-[active=true]:bg-sidebar-active data-[active=true]:font-semibold data-[active=true]:text-sidebar-active-foreground data-[active=true]:hover:bg-sidebar-active data-[active=true]:hover:text-sidebar-active-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
+2
View File
@@ -46,6 +46,8 @@ export default {
foreground: "hsl(var(--sidebar-foreground))",
primary: "hsl(var(--sidebar-primary))",
"primary-foreground": "hsl(var(--sidebar-primary-foreground))",
active: "hsl(var(--sidebar-active))",
"active-foreground": "hsl(var(--sidebar-active-foreground))",
accent: "hsl(var(--sidebar-accent))",
"accent-foreground": "hsl(var(--sidebar-accent-foreground))",
border: "hsl(var(--sidebar-border))",
+3 -1
View File
@@ -285,7 +285,9 @@ test("create ephemeral stream shows sidebar and header affordances", async ({
/Ephemeral channel\. Cleans up (tomorrow|in \d+ hours?)\./,
);
await page.getByRole("button", { name: "Toggle Sidebar" }).click();
await page
.getByRole("button", { name: "Toggle Sidebar", exact: true })
.click();
await expect(
page.getByTestId(`channel-ephemeral-${channelName}`),
).toBeVisible();
+11
View File
@@ -72,6 +72,17 @@ test("updates presence from the profile menu", async ({ page }) => {
).toContainText("Offline");
});
test("disables sidebar resize while settings are open", async ({ page }) => {
await page.goto("/");
const sidebarRail = page.locator('[data-sidebar="rail"]');
await expect(sidebarRail).toBeEnabled();
await openSettings(page, "appearance");
await expect(sidebarRail).toBeDisabled();
await expect(sidebarRail).toHaveCSS("pointer-events", "none");
});
test("notification settings drive the Home badge and desktop alerts", async ({
page,
}) => {
+68
View File
@@ -0,0 +1,68 @@
import { expect, test, type Page } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
const SIDEBAR_WIDTH_STORAGE_KEY = "sprout-sidebar-width";
const DEFAULT_SIDEBAR_WIDTH = 300;
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
});
async function sidebarWidth(page: Page) {
return page.getByTestId("app-sidebar").evaluate((element) => {
return Math.round(element.getBoundingClientRect().width);
});
}
async function storedSidebarWidth(page: Page) {
return page.evaluate(
(key) => localStorage.getItem(key),
SIDEBAR_WIDTH_STORAGE_KEY,
);
}
async function dragSidebarRail(page: Page, deltaX: number) {
const sidebarRail = page.locator('[data-sidebar="rail"]');
await expect(sidebarRail).toBeVisible();
await expect(sidebarRail).toBeEnabled();
const box = await sidebarRail.boundingBox();
expect(box).not.toBeNull();
if (!box) return;
const startX = box.x + box.width / 2;
const startY = box.y + box.height / 2;
await page.mouse.move(startX, startY);
await page.mouse.down();
await page.mouse.move(startX + deltaX, startY, { steps: 8 });
await page.mouse.up();
}
test("resizes, persists, and snaps to the default sidebar width", async ({
page,
}) => {
await page.goto("/");
await expect(page.getByTestId("app-sidebar")).toBeVisible();
await expect.poll(() => sidebarWidth(page)).toBe(DEFAULT_SIDEBAR_WIDTH);
await expect.poll(() => storedSidebarWidth(page)).toBeNull();
await dragSidebarRail(page, 64);
await expect.poll(() => sidebarWidth(page)).toBe(364);
await expect.poll(() => storedSidebarWidth(page)).toBe("364");
await page.reload();
await expect(page.getByTestId("app-sidebar")).toBeVisible();
await expect.poll(() => sidebarWidth(page)).toBe(364);
await dragSidebarRail(page, -60);
await expect.poll(() => sidebarWidth(page)).toBe(DEFAULT_SIDEBAR_WIDTH);
await expect
.poll(() => storedSidebarWidth(page))
.toBe(String(DEFAULT_SIDEBAR_WIDTH));
});