fix: tighten composable sidebar primitives

This commit is contained in:
Jesse_Chen
2026-07-17 17:32:42 +08:00
parent c45f21e8d8
commit 62382d9828
3 changed files with 47 additions and 11 deletions
+16 -8
View File
@@ -81,6 +81,7 @@ export function SidebarProvider({
const { viewport, ready } = useSidebarViewport();
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
const [openMobile, setOpenMobileState] = useState(false);
const openMobileRef = useRef(false);
const userChangedDesktopState = useRef(false);
const insetTriggerRef = useRef<HTMLButtonElement | null>(null);
const sidebarTriggerRef = useRef<HTMLButtonElement | null>(null);
@@ -96,6 +97,8 @@ export function SidebarProvider({
}, [isControlled, onOpenChange]);
const setOpenMobile = useCallback((nextOpen: boolean) => {
if (openMobileRef.current === nextOpen) return;
openMobileRef.current = nextOpen;
setOpenMobileState(nextOpen);
onMobileOpenChange?.(nextOpen);
}, [onMobileOpenChange]);
@@ -107,10 +110,10 @@ export function SidebarProvider({
}, [isControlled, ready, viewport]);
useEffect(() => {
if (viewport === "mobile") return;
if (viewport === "mobile" || !openMobile) return;
const closeMobileDrawer = window.requestAnimationFrame(() => setOpenMobile(false));
return () => window.cancelAnimationFrame(closeMobileDrawer);
}, [setOpenMobile, viewport]);
}, [openMobile, setOpenMobile, viewport]);
useEffect(() => {
const handleKeydown = (event: KeyboardEvent) => {
@@ -168,9 +171,12 @@ export function SidebarProvider({
);
}
export function Sidebar({ className, ...props }: ComponentProps<"aside">) {
const { open, openMobile } = useSidebar();
return <aside data-sidebar="sidebar" data-slot="sidebar" data-state={open ? "expanded" : "collapsed"} data-mobile-open={openMobile} className={cn("flex min-h-0 shrink-0 flex-col", className)} {...props} />;
export function Sidebar({ id, className, ...props }: ComponentProps<"aside">) {
const { isMobile, open, openMobile, setOpenMobile } = useSidebar();
return <>
<button type="button" data-sidebar="scrim" data-slot="sidebar-scrim" data-mobile-open={isMobile && openMobile} aria-label="关闭聊天记录" aria-hidden={!isMobile || !openMobile} tabIndex={isMobile && openMobile ? 0 : -1} className="sidebar-scrim" onClick={() => setOpenMobile(false)} />
<aside id={id ?? "chat-sidebar"} data-sidebar="sidebar" data-slot="sidebar" data-state={open ? "expanded" : "collapsed"} data-mobile-open={openMobile} className={cn("flex min-h-0 shrink-0 flex-col", className)} {...props} />
</>;
}
export function SidebarHeader({ className, ...props }: ComponentProps<"div">) {
@@ -230,7 +236,9 @@ export const SidebarTrigger = forwardRef<HTMLButtonElement, SidebarTriggerProps>
}, ref) {
const { isMobile, open, openMobile, setInsetTrigger, setSidebarTrigger, toggleSidebar } = useSidebarContext();
const expanded = isMobile ? openMobile : open;
const label = expanded ? "Close sidebar" : "Open sidebar";
const label = isMobile
? openMobile ? "关闭聊天记录" : "打开聊天记录"
: open ? "收起侧边栏" : "展开侧边栏";
const register = (element: HTMLButtonElement | null) => {
setRef(ref, element);
if (placement === "inset") setInsetTrigger(element);
@@ -239,9 +247,9 @@ export const SidebarTrigger = forwardRef<HTMLButtonElement, SidebarTriggerProps>
return <button ref={register} type="button" data-sidebar="trigger" data-slot="sidebar-trigger" aria-label={label} aria-expanded={expanded} className={cn("min-h-11 min-w-11", className)} onClick={(event) => { onClick?.(event); if (!event.defaultPrevented) toggleSidebar(); }} {...props} />;
});
export function SidebarRail({ className, ...props }: ComponentProps<"button">) {
export function SidebarRail({ className, onClick, ...props }: ComponentProps<"button">) {
const { isMobile, open, setOpen } = useSidebar();
if (isMobile) return null;
const label = open ? "Collapse sidebar" : "Expand sidebar";
return <button type="button" data-sidebar="rail" data-slot="sidebar-rail" aria-label={label} className={cn("min-h-11 min-w-11", className)} onClick={() => setOpen(!open)} {...props} />;
return <button type="button" data-sidebar="rail" data-slot="sidebar-rail" aria-label={label} className={cn("min-h-11 min-w-11", className)} onClick={(event) => { onClick?.(event); if (!event.defaultPrevented) setOpen(!open); }} {...props} />;
}
+2 -1
View File
@@ -19,7 +19,8 @@ export function useSidebarViewport(): SidebarViewportState {
useEffect(() => {
const updateViewport = () => {
setState({ viewport: sidebarViewportForWidth(window.innerWidth), ready: true });
const viewport = sidebarViewportForWidth(window.innerWidth);
setState((previous) => previous.ready && previous.viewport === viewport ? previous : { viewport, ready: true });
};
updateViewport();
+29 -2
View File
@@ -32,14 +32,41 @@ test("keeps sidebar behavior in the generic primitive", () => {
assert.match(sidebar, /data-mobile-open/);
assert.match(sidebar, /addEventListener\("keydown"/);
assert.match(sidebar, /preventDefault\(\)/);
assert.match(sidebar, /const label = expanded \? "Close sidebar" : "Open sidebar"/);
assert.match(sidebar, /viewport === "mobile"\) return;[\s\S]*setOpenMobile\(false\)/);
assert.match(sidebar, /viewport === "mobile" \|\| !openMobile\) return;[\s\S]*setOpenMobile\(false\)/);
assert.match(sidebar, /@base-ui\/react\/tooltip/);
assert.doesNotMatch(sidebar, /Sheet/);
assert.match(sidebar, /cn\(/);
assert.doesNotMatch(sidebar, /#[0-9a-fA-F]{3,8}|hsl\(/);
});
test("provides the mobile drawer closing surface", () => {
const sidebar = readProjectFile("src/components/ui/sidebar.tsx");
assert.match(sidebar, /data-sidebar="scrim"/);
assert.match(sidebar, /data-slot="sidebar-scrim"/);
assert.match(sidebar, /aria-label="关闭聊天记录"/);
assert.match(sidebar, /onClick=\{\(\) => setOpenMobile\(false\)\}/);
});
test("uses the approved localized trigger actions", () => {
const sidebar = readProjectFile("src/components/ui/sidebar.tsx");
assert.match(sidebar, /"收起侧边栏"/);
assert.match(sidebar, /"展开侧边栏"/);
assert.match(sidebar, /"打开聊天记录"/);
assert.match(sidebar, /"关闭聊天记录"/);
});
test("keeps provider primitive defaults and consumer handlers composable", () => {
const sidebar = readProjectFile("src/components/ui/sidebar.tsx");
assert.match(sidebar, /id=\{id \?\? "chat-sidebar"\}/);
assert.match(sidebar, /if \(viewport === "mobile" \|\| !openMobile\) return;/);
assert.match(sidebar, /onClick\?\.\(event\);\s*if \(!event\.defaultPrevented\) setOpen\(!open\);/);
});
test("retains viewport state within an unchanged breakpoint", () => {
const viewportHook = readProjectFile("src/hooks/use-sidebar-viewport.ts");
assert.match(viewportHook, /previous\.ready && previous\.viewport === viewport \? previous : \{ viewport, ready: true \}/);
});
test("documents the sidebar shell design contract", () => {
const design = readProjectFile("DESIGN.md");
assert.match(design, /### Sidebar shell/);