Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion apps/www/src/content/docs/components/sidebar/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,24 @@ The sidebar expands and collapses, animating between the two. Collapsed keeps ic

<Demo data={stateDemo} />

### External control

`Sidebar.Trigger` and `useSidebar` work only inside `<Sidebar>`. To toggle the sidebar from elsewhere, for example a top bar, control it with `open` and `onOpenChange`:

```tsx
const [open, setOpen] = useState(true);

<IconButton
aria-label="Toggle sidebar"
aria-expanded={open}
aria-controls="app-sidebar"
onClick={() => setOpen(!open)}
>
<PanelLeft />
</IconButton>
<Sidebar id="app-sidebar" open={open} onOpenChange={setOpen}>…</Sidebar>
```

### Non-collapsible

Set `collapsible="none"` to hide the resize handle and prevent the sidebar from being collapsed or expanded.
Expand All @@ -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.

<Demo data={peekOnHoverDemo} />

### Custom tooltip message
Expand Down Expand Up @@ -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 `<Sidebar>` instead, or drive `open`/`onOpenChange` yourself with a toggle button that lives outside `<Sidebar>` 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 `<Sidebar>` instead, or use [external control](#external-control).*

<auto-type-table path="./props.ts" name="SidebarTriggerProps" />

Expand Down
3 changes: 3 additions & 0 deletions apps/www/src/content/docs/components/sidebar/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
*/
Expand Down
14 changes: 14 additions & 0 deletions packages/raystack/components/sidebar/__tests__/sidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,20 @@ describe('Sidebar', () => {
);
});

it('reports peek start and end via onPeekChange', async () => {
const onPeekChange = vi.fn();
render(<Sidebar open={false} peekOnHover onPeekChange={onPeekChange} />);
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(
Expand Down
10 changes: 10 additions & 0 deletions packages/raystack/components/sidebar/sidebar-root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -118,6 +120,7 @@ export function SidebarRoot({
collapsible = 'icon',
peekOnHover = false,
peekDelay = DEFAULT_PEEK_DELAY,
onPeekChange,
collapseTooltip,
defaultOpen = true,
children,
Expand Down Expand Up @@ -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]);
Comment thread
rohanchkrabrty marked this conversation as resolved.

// 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).
Expand Down
5 changes: 5 additions & 0 deletions packages/raystack/components/sidebar/sidebar.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment thread
rohanchkrabrty marked this conversation as resolved.
}

.root[data-peeking][data-position="left"] {
margin-right: calc(
var(--sidebar-collapsed-width) -
Expand Down
Loading