diff --git a/apps/www/src/content/docs/components/sidebar/index.mdx b/apps/www/src/content/docs/components/sidebar/index.mdx index 2f5eea99b..cccb4656b 100644 --- a/apps/www/src/content/docs/components/sidebar/index.mdx +++ b/apps/www/src/content/docs/components/sidebar/index.mdx @@ -91,6 +91,24 @@ The sidebar expands and collapses, animating between the two. Collapsed keeps ic +### External control + +`Sidebar.Trigger` and `useSidebar` work only inside ``. To toggle the sidebar from elsewhere, for example a top bar, control it with `open` and `onOpenChange`: + +```tsx +const [open, setOpen] = useState(true); + + setOpen(!open)} +> + + +… +``` + ### Non-collapsible Set `collapsible="none"` to hide the resize handle and prevent the sidebar from being collapsed or expanded. @@ -114,6 +132,9 @@ so hover the thin strip at the edge instead, as the "Hidden" tab below shows. The peek waits 100ms so it does not fire when the mouse only passes over. Change that with `peekDelay`. +`onPeekChange` reports when a peek starts or ends, for example to dim the page +behind the sidebar. + ### Custom tooltip message @@ -196,7 +217,7 @@ The footer section is a container that accepts all `div` props. It's commonly us A button that toggles the sidebar open/closed. It works whether the Sidebar is controlled or uncontrolled, so you can drop it into a header without wiring up any state yourself. It accepts all `IconButton` props (e.g. `size`) plus the ones below. -*Note: disabled automatically when the Sidebar has `collapsible="none"`. If you use `collapsible="hidden"`, don't place `Sidebar.Trigger` inside `Sidebar.Header`/`Main`/`Footer`, because those sections are hidden along with the rest of the collapsed content, taking the trigger with them. Render it as a direct child of `` instead, or drive `open`/`onOpenChange` yourself with a toggle button that lives outside `` entirely (e.g. in your app's top bar).* +*Note: disabled automatically when the Sidebar has `collapsible="none"`. If you use `collapsible="hidden"`, don't place `Sidebar.Trigger` inside `Sidebar.Header`/`Main`/`Footer`, because those sections are hidden along with the rest of the collapsed content, taking the trigger with them. Render it as a direct child of `` instead, or use [external control](#external-control).* diff --git a/apps/www/src/content/docs/components/sidebar/props.ts b/apps/www/src/content/docs/components/sidebar/props.ts index d1038ac7e..9c8fe01b8 100644 --- a/apps/www/src/content/docs/components/sidebar/props.ts +++ b/apps/www/src/content/docs/components/sidebar/props.ts @@ -36,6 +36,9 @@ export interface SidebarRootProps { */ peekDelay?: number; + /** Called when a hover peek starts or ends. */ + onPeekChange?: (isPeeking: boolean) => void; + /** Position of the Sidebar. * @default "left" */ diff --git a/packages/raystack/components/sidebar/__tests__/sidebar.test.tsx b/packages/raystack/components/sidebar/__tests__/sidebar.test.tsx index 99bb3da4f..381e2772d 100644 --- a/packages/raystack/components/sidebar/__tests__/sidebar.test.tsx +++ b/packages/raystack/components/sidebar/__tests__/sidebar.test.tsx @@ -323,6 +323,20 @@ describe('Sidebar', () => { ); }); + it('reports peek start and end via onPeekChange', async () => { + const onPeekChange = vi.fn(); + render(); + expect(onPeekChange).not.toHaveBeenCalled(); + + const nav = screen.getByRole('navigation'); + fireEvent.mouseEnter(nav); + await waitFor(() => expect(onPeekChange).toHaveBeenCalledWith(true)); + + fireEvent.mouseLeave(nav); + await waitFor(() => expect(onPeekChange).toHaveBeenLastCalledWith(false)); + expect(onPeekChange).toHaveBeenCalledTimes(2); + }); + it('pins the sidebar open when the handle is clicked while peeking', async () => { const onOpenChange = vi.fn(); render( diff --git a/packages/raystack/components/sidebar/sidebar-root.tsx b/packages/raystack/components/sidebar/sidebar-root.tsx index e10dec210..de6374c8b 100644 --- a/packages/raystack/components/sidebar/sidebar-root.tsx +++ b/packages/raystack/components/sidebar/sidebar-root.tsx @@ -96,6 +96,8 @@ export interface SidebarRootProps extends ComponentProps<'aside'> { * @default 100 */ peekDelay?: number; + /** Called when a hover peek starts or ends. */ + onPeekChange?: (isPeeking: boolean) => void; /** Tooltip shown when hovering the collapse/expand handle. */ collapseTooltip?: ReactNode; open?: boolean; @@ -118,6 +120,7 @@ export function SidebarRoot({ collapsible = 'icon', peekOnHover = false, peekDelay = DEFAULT_PEEK_DELAY, + onPeekChange, collapseTooltip, defaultOpen = true, children, @@ -187,6 +190,13 @@ export function SidebarRoot({ setIsPeeking(false); }, [open]); + const lastPeekRef = useRef(false); + useEffect(() => { + if (lastPeekRef.current === isPeeking) return; + lastPeekRef.current = isPeeking; + onPeekChange?.(isPeeking); + }, [isPeeking, onPeekChange]); + // data-open/data-closed drive the visuals, so a peek counts as open, // every collapse-hiding CSS rule turns off during a peek for free. The // real state stays in `open` (and the toggle controls' aria-expanded). diff --git a/packages/raystack/components/sidebar/sidebar.module.css b/packages/raystack/components/sidebar/sidebar.module.css index d10188979..58c1f3b0e 100644 --- a/packages/raystack/components/sidebar/sidebar.module.css +++ b/packages/raystack/components/sidebar/sidebar.module.css @@ -74,6 +74,11 @@ box-shadow: var(--rs-shadow-lifted); } +/* Inset is transparent, so its overlay needs a background to hide the content under it. */ +.root[data-peeking][data-variant="inset"] { + background: var(--rs-color-background-base-primary); +} + .root[data-peeking][data-position="left"] { margin-right: calc( var(--sidebar-collapsed-width) -