mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Refine sidebar behavior and borders (#869)
This commit is contained in:
@@ -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",
|
||||
|
||||
Generated
+1
@@ -8678,6 +8678,7 @@ dependencies = [
|
||||
"mesh-llm-sdk",
|
||||
"neteq",
|
||||
"nostr",
|
||||
"objc2-app-kit",
|
||||
"opus",
|
||||
"png 0.18.1",
|
||||
"regex",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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))",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
Reference in New Issue
Block a user