Skip to content
Draft
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
15 changes: 15 additions & 0 deletions .changeset/brave-otters-bake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@coveord/plasma-mantine': minor
---

Add `footer` and toggle options to `Navigation.SideBar`, and `disabled` to `Navigation.Toggle`

`Navigation.SideBar` accepts a `footer` that stays pinned below the scrollable links, `withToggle={false}` to remove the collapse toggle, and `toggleProps` to customize it, for example `toggleProps={{disabled: true}}` to keep it visible while preventing the sidebar from being collapsed or expanded.

`Navigation.Toggle` now also responds to clicks anywhere in its strip along the sidebar edge, not only on the chevron button.

```tsx
<Navigation.SideBar header={<Logo />} footer={<SettingsLink />} toggleProps={{disabled: isLocked}}>
{links}
</Navigation.SideBar>
```
7 changes: 6 additions & 1 deletion packages/llms/src/components/Navigation.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,15 @@ description: Sidebar navigation component with collapsible sections, active link
> Extends: `AppShellNavbarProps` (excluding `hidden`).

**`header`** `ReactNode` · optional — Content rendered at the top of the sidebar (e.g., a logo or app switcher slot).
**`footer`** `ReactNode` · optional — Content pinned at the bottom of the sidebar, below the scrollable links.
**`withToggle`** `boolean` · optional · default: `true` — Whether the collapse toggle is rendered. Set it to `false` when users must not collapse the sidebar.
**`toggleProps`** `Omit<NavigationToggleProps, 'className'>` · optional — Props passed down to the collapse toggle (labels, disabled state).

### Navigation.Toggle

**`expandLabel`** `string` · optional · default: `'Expand'` — Accessible label for the expand button.
**`collapseLabel`** `string` · optional · default: `'Collapse'` — Accessible label for the collapse button.
**`disabled`** `boolean` · optional · default: `false` — Whether the toggle is disabled. A disabled toggle stays visible but does not change the collapsed state.

## Sub-components

Expand Down Expand Up @@ -128,7 +132,8 @@ function Example() {

- Use `level={1}` for top-level items (standalone links or sections) and `level={2}` for nested links within a section.
- Pass a `component` prop to `Navigation.Link` to integrate with your router (e.g., React Router's `Link`).
- The `Navigation.Toggle` is automatically included at the bottom of `Navigation.SideBar`.
- The `Navigation.Toggle` is automatically included in `Navigation.SideBar`. Use `withToggle={false}` to remove it, or `toggleProps={{disabled: true}}` to keep it visible but inactive.
- Use the `footer` prop for content that must stay visible below the links, such as a call-to-action link. It does not scroll with the links.

---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import clsx from 'clsx';
import {FunctionComponent, ReactNode} from 'react';
import {useNavigation} from './Navigation.context.js';
import classes from './NavigationSideBar.module.css';
import {NavigationToggle} from './NavigationToggle.js';
import {NavigationToggle, type NavigationToggleProps} from './NavigationToggle.js';

export type NavigationSideBarStylesNames = 'navbar';

Expand All @@ -12,6 +12,19 @@ export interface NavigationSideBarProps extends Omit<AppShellNavbarProps, 'hidde
* Content rendered at the top of the sidebar (e.g., a logo or app switcher).
*/
header?: ReactNode;
/**
* Content pinned at the bottom of the sidebar, below the scrollable links.
*/
footer?: ReactNode;
/**
* Whether the collapse toggle is rendered.
* @default true
*/
withToggle?: boolean;
/**
* Props passed down to the collapse toggle, e.g. its labels or disabled state.
*/
toggleProps?: Omit<NavigationToggleProps, 'className'>;
}

export type NavigationSideBarFactory = Factory<{
Expand All @@ -20,13 +33,13 @@ export type NavigationSideBarFactory = Factory<{
stylesNames: NavigationSideBarStylesNames;
}>;

const defaultProps: Partial<NavigationSideBarProps> = {};
const defaultProps: Partial<NavigationSideBarProps> = {withToggle: true};

export const NavigationSideBar: FunctionComponent<NavigationSideBarProps> = factory<NavigationSideBarFactory>(
({ref, ..._props}) => {
const {collapsed} = useNavigation();
const props = useProps('NavigationSideBar', defaultProps, _props);
const {children, header, ...rest} = props;
const {children, header, footer, withToggle, toggleProps, ...rest} = props;

const isSafari = typeof navigator !== 'undefined' && /apple/i.test(navigator.vendor);

Expand All @@ -51,7 +64,8 @@ export const NavigationSideBar: FunctionComponent<NavigationSideBarProps> = fact
{children}
</Stack>
</AppShell.Section>
<NavigationToggle className={classes.collapseToggle} />
{footer ? <AppShell.Section w="100%">{footer}</AppShell.Section> : null}
{withToggle ? <NavigationToggle className={classes.collapseToggle} {...toggleProps} /> : null}
</AppShell.Navbar>
);
},
Expand Down
17 changes: 15 additions & 2 deletions packages/mantine/src/components/Navigation/NavigationToggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,24 +17,37 @@ export interface NavigationToggleProps {
* @default 'Collapse'
*/
collapseLabel?: string;
/**
* Whether the toggle is disabled. A disabled toggle stays visible but does not change the collapsed state.
* @default false
*/
disabled?: boolean;
}

export const NavigationToggle: FunctionComponent<NavigationToggleProps> = ({
className,
expandLabel = 'Expand',
collapseLabel = 'Collapse',
disabled = false,
}) => {
const {toggleCollapsed, collapsed} = useNavigation();
const Icon = collapsed ? IconChevronRight : IconChevronLeft;

// The click is handled on the container so the whole strip next to the navbar edge toggles, not only the button.
const onClick = () => {
if (!disabled) {
toggleCollapsed();
}
};

return (
<Box className={clsx(className, classes.toggleContainer)} mod={{collapsed}}>
<Box className={clsx(className, classes.toggleContainer)} mod={{collapsed, disabled}} onClick={onClick}>
<ActionIcon
size="sm"
className={classes.toggle}
variant="filled"
aria-label={collapsed ? expandLabel : collapseLabel}
onClick={() => toggleCollapsed()}
disabled={disabled}
>
<Icon />
</ActionIcon>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,98 @@ describe('Navigation', () => {
await user.click(screen.getByRole('button', {name: 'Expand'}));
expect(navigation).not.toHaveAttribute('data-collapsed');
});

it('toggles the Navigation.SideBar with the keyboard', async () => {
const user = userEvent.setup();
render(
<AppShell>
<Navigation>
<Navigation.SideBar />
</Navigation>
</AppShell>,
);
const navigation = screen.getByRole('navigation');

screen.getByRole('button', {name: 'Collapse'}).focus();
await user.keyboard('{Enter}');
expect(navigation).toHaveAttribute('data-collapsed', 'true');

await user.keyboard('{Enter}');
expect(navigation).not.toHaveAttribute('data-collapsed');
});

it('does not toggle the Navigation.SideBar when disabled', async () => {
const user = userEvent.setup();
render(
<AppShell>
<Navigation>
<Navigation.SideBar toggleProps={{disabled: true}} />
</Navigation>
</AppShell>,
);

const toggle = screen.getByRole('button', {name: 'Collapse'});
expect(toggle).toBeDisabled();

await user.click(toggle);
expect(screen.getByRole('navigation')).not.toHaveAttribute('data-collapsed');
});

it('uses the labels passed through toggleProps', () => {
render(
<AppShell>
<Navigation defaultCollapsed>
<Navigation.SideBar toggleProps={{expandLabel: 'Show menu', collapseLabel: 'Hide menu'}} />
</Navigation>
</AppShell>,
);

expect(screen.getByRole('button', {name: 'Show menu'})).toBeInTheDocument();
});
});

describe('NavigationSideBar', () => {
it('renders the collapse toggle by default', () => {
render(
<AppShell>
<Navigation>
<Navigation.SideBar />
</Navigation>
</AppShell>,
);

expect(screen.getByRole('button', {name: 'Collapse'})).toBeInTheDocument();
});

it('does not render the collapse toggle when withToggle is false', () => {
render(
<AppShell>
<Navigation>
<Navigation.SideBar withToggle={false} />
</Navigation>
</AppShell>,
);

expect(screen.queryByRole('button', {name: 'Collapse'})).not.toBeInTheDocument();
});

it('renders the header, the links and the footer in order', () => {
render(
<AppShell>
<Navigation>
<Navigation.SideBar header={<div>Header</div>} footer={<div>Footer</div>}>
<Navigation.Link level={1} label="Home" />
</Navigation.SideBar>
</Navigation>
</AppShell>,
);

const header = screen.getByText('Header');
const link = screen.getByText('Home');
const footer = screen.getByText('Footer');
expect(header.compareDocumentPosition(link) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(link.compareDocumentPosition(footer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
});

describe('NavigationSection', () => {
Expand Down
4 changes: 3 additions & 1 deletion packages/storybook/src/components/layout/Navigation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ The `Navigation` provider coordinates a sidebar's collapsed state with an `AppSh
- Wrap the shell with `Navigation`, then use `useNavigation` to pass the collapsed state to the `AppShell` navbar.
- Use level 1 for top-level links and level 2 for links inside a section.
- Mark the current destination as active and provide a router component when navigation changes routes.
- Let `Navigation.SideBar` provide its collapse toggle. Sections with no children are omitted automatically.
- Let `Navigation.SideBar` provide its collapse toggle. Hide it with `withToggle={false}` only when collapsing must not be possible, and disable it with `toggleProps` when collapsing is temporarily unavailable.
- Use `footer` for a destination that must stay visible below the links. Keep it to one or two items.
- Sections with no children are omitted automatically.

### Content guidelines

Expand Down
42 changes: 39 additions & 3 deletions packages/storybook/src/components/layout/Navigation.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {AppShell} from '@coveord/plasma-mantine/components/AppShell';
import {Center} from '@coveord/plasma-mantine/components/Center';
import {Navigation, useNavigation} from '@coveord/plasma-mantine/components/Navigation';
import {Text} from '@coveord/plasma-mantine/components/Text';
import {IconDatabase, IconHome} from '@coveord/plasma-react-icons';
import {IconDatabase, IconHome, IconSettings} from '@coveord/plasma-react-icons';
import type {Meta, StoryObj} from '@storybook/react-vite';
import {CSSProperties, FunctionComponent, PropsWithChildren, useEffect} from 'react';
import {useArgs, useGlobals} from 'storybook/preview-api';
Expand Down Expand Up @@ -67,12 +67,15 @@ const Shell: FunctionComponent<PropsWithChildren<{onCollapsedChange?: (collapsed
);
};

const links = ['Home', 'Sources', 'Catalogs', 'Fields'] as const;
const links = ['Home', 'Sources', 'Catalogs', 'Fields', 'Settings'] as const;

interface DemoArgs {
collapsed: boolean;
activeLink: string;
defaultCollapsed: boolean;
withToggle: boolean;
toggleDisabled: boolean;
withFooter: boolean;
}

export const Demo: Story = {
Expand All @@ -86,10 +89,25 @@ export const Demo: Story = {
control: 'boolean',
description: 'Current collapsed state (read-only, synced from toggle).',
},
withToggle: {
control: 'boolean',
description: 'Whether the collapse toggle is rendered.',
},
toggleDisabled: {
control: 'boolean',
description: 'Whether the collapse toggle is disabled (passed through `toggleProps`).',
},
withFooter: {
control: 'boolean',
description: 'Whether to render content in the `footer` slot.',
},
},
args: {
activeLink: 'Home',
collapsed: false,
withToggle: true,
toggleDisabled: false,
withFooter: true,
},
render: (args) => {
const [{activeLink}, updateArgs] = useArgs<DemoArgs>();
Expand All @@ -100,7 +118,25 @@ export const Demo: Story = {
return (
<Navigation defaultCollapsed={args.defaultCollapsed}>
<Shell onCollapsedChange={(newCollapsed) => updateArgs({collapsed: newCollapsed})}>
<Navigation.SideBar header={<Logo />} style={navigationColors[primaryColor]}>
<Navigation.SideBar
header={<Logo />}
footer={
args.withFooter ? (
<Navigation.Link
level={1}
label="Settings"
leftSection={<IconSettings size={20} />}
active={activeLink === 'Settings'}
onClick={() => setActive('Settings')}
mx="xs"
w="auto"
/>
) : undefined
}
withToggle={args.withToggle}
toggleProps={{disabled: args.toggleDisabled}}
style={navigationColors[primaryColor]}
>
<Navigation.Link
level={1}
label="Home"
Expand Down
Loading