@hyphen/hyphen-components 7.8.0 → 7.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/css/utilities.css +1 -1
- package/dist/css/variables.css +5 -5
- package/dist/hyphen-components.cjs.development.js.map +1 -1
- package/dist/hyphen-components.cjs.production.min.js.map +1 -1
- package/dist/hyphen-components.esm.js.map +1 -1
- package/dist/index.d.ts +42 -0
- package/package.json +3 -2
- package/src/components/FormControl/FormControl.stories.tsx +92 -0
- package/src/components/HelpText/HelpText.stories.tsx +45 -0
- package/src/components/InputValidationMessage/InputValidationMessage.stories.tsx +50 -0
- package/src/components/Sidebar/Sidebar.mdx +208 -0
- package/src/components/Sidebar/Sidebar.stories.tsx +13 -0
- package/src/components/Sidebar/Sidebar.tsx +42 -0
package/dist/index.d.ts
CHANGED
|
@@ -2271,17 +2271,49 @@ declare function useSidebar(sideOverride?: SidebarSide): {
|
|
|
2271
2271
|
toggleSidebar: () => void;
|
|
2272
2272
|
};
|
|
2273
2273
|
declare const SidebarProvider: React__default.ForwardRefExoticComponent<Omit<React__default.ClassAttributes<HTMLDivElement> & React__default.HTMLAttributes<HTMLDivElement> & {
|
|
2274
|
+
/**
|
|
2275
|
+
* Initial open state for uncontrolled usage. Pass a boolean for both
|
|
2276
|
+
* sides, or `{ left, right }` to set each side independently.
|
|
2277
|
+
*/
|
|
2274
2278
|
defaultOpen?: SidebarOpenValue;
|
|
2279
|
+
/**
|
|
2280
|
+
* Controlled open state. Pass a boolean for both sides, or
|
|
2281
|
+
* `{ left, right }` to control each side independently.
|
|
2282
|
+
*/
|
|
2275
2283
|
open?: SidebarOpenValue;
|
|
2284
|
+
/**
|
|
2285
|
+
* localStorage key(s) the open state is written to when a sidebar is
|
|
2286
|
+
* toggled. A string applies to the left sidebar, with `_right` appended
|
|
2287
|
+
* for the right sidebar; pass `{ left, right }` to set each explicitly.
|
|
2288
|
+
*/
|
|
2276
2289
|
storageKey?: SidebarStorageKey;
|
|
2290
|
+
/**
|
|
2291
|
+
* Called when a sidebar is toggled, with the new open state and which
|
|
2292
|
+
* side was toggled.
|
|
2293
|
+
*/
|
|
2277
2294
|
onOpenChange?: (open: boolean, side?: SidebarSide) => void;
|
|
2278
2295
|
}, "ref"> & React__default.RefAttributes<HTMLDivElement>>;
|
|
2279
2296
|
declare const Sidebar: React__default.ForwardRefExoticComponent<Omit<React__default.ClassAttributes<HTMLDivElement> & React__default.HTMLAttributes<HTMLDivElement> & {
|
|
2297
|
+
/**
|
|
2298
|
+
* Which edge of the viewport the sidebar is attached to.
|
|
2299
|
+
*/
|
|
2280
2300
|
side?: "left" | "right";
|
|
2301
|
+
/**
|
|
2302
|
+
* Collapse behavior when the sidebar is toggled closed: slide fully out
|
|
2303
|
+
* of view (`offcanvas`), collapse to a narrow icon strip (`icon`), or
|
|
2304
|
+
* stay at full width (`none`).
|
|
2305
|
+
*/
|
|
2281
2306
|
collapsible?: "offcanvas" | "icon" | "none";
|
|
2282
2307
|
}, "ref"> & React__default.RefAttributes<HTMLDivElement>>;
|
|
2283
2308
|
declare const SidebarTrigger: React__default.ForwardRefExoticComponent<Omit<BaseButtonProps & React__default.ButtonHTMLAttributes<HTMLButtonElement> & React__default.RefAttributes<HTMLButtonElement> & {
|
|
2309
|
+
/**
|
|
2310
|
+
* Which sidebar to toggle. Defaults to the side of the containing
|
|
2311
|
+
* Sidebar, or `left` outside of one.
|
|
2312
|
+
*/
|
|
2284
2313
|
side?: SidebarSide;
|
|
2314
|
+
/**
|
|
2315
|
+
* Icon shown on the trigger button.
|
|
2316
|
+
*/
|
|
2285
2317
|
iconName?: IconName;
|
|
2286
2318
|
}, "ref"> & React__default.RefAttributes<HTMLButtonElement>>;
|
|
2287
2319
|
declare const SidebarInset: React__default.ForwardRefExoticComponent<Omit<React__default.DetailedHTMLProps<React__default.HTMLAttributes<HTMLElement>, HTMLElement>, "ref"> & React__default.RefAttributes<HTMLDivElement>>;
|
|
@@ -2291,8 +2323,18 @@ declare const SidebarContent: React__default.ForwardRefExoticComponent<Omit<Reac
|
|
|
2291
2323
|
declare const SidebarMenu: React__default.ForwardRefExoticComponent<Omit<React__default.DetailedHTMLProps<React__default.HTMLAttributes<HTMLUListElement>, HTMLUListElement>, "ref"> & React__default.RefAttributes<HTMLUListElement>>;
|
|
2292
2324
|
declare const SidebarMenuItem: React__default.ForwardRefExoticComponent<Omit<React__default.DetailedHTMLProps<React__default.LiHTMLAttributes<HTMLLIElement>, HTMLLIElement>, "ref"> & React__default.RefAttributes<HTMLLIElement>>;
|
|
2293
2325
|
declare const SidebarMenuButton: React__default.ForwardRefExoticComponent<Omit<React__default.ClassAttributes<HTMLButtonElement> & React__default.ButtonHTMLAttributes<HTMLButtonElement> & {
|
|
2326
|
+
/**
|
|
2327
|
+
* Render the child element (e.g. a router link) instead of a `button`.
|
|
2328
|
+
*/
|
|
2294
2329
|
asChild?: boolean;
|
|
2330
|
+
/**
|
|
2331
|
+
* Marks the item as the current page.
|
|
2332
|
+
*/
|
|
2295
2333
|
isActive?: boolean;
|
|
2334
|
+
/**
|
|
2335
|
+
* Tooltip shown when the sidebar is collapsed to icons (desktop only).
|
|
2336
|
+
* Pass a string, or TooltipContent props for full control.
|
|
2337
|
+
*/
|
|
2296
2338
|
tooltip?: string | React__default.ComponentProps<typeof TooltipContent>;
|
|
2297
2339
|
}, "ref"> & React__default.RefAttributes<HTMLButtonElement>>;
|
|
2298
2340
|
declare const SidebarGroup: React__default.ForwardRefExoticComponent<Omit<React__default.DetailedHTMLProps<React__default.HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "ref"> & React__default.RefAttributes<HTMLDivElement>>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyphen/hyphen-components",
|
|
3
|
-
"version": "7.8.
|
|
3
|
+
"version": "7.8.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "@hyphen"
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
"react-select": "^5.10.0",
|
|
57
57
|
"react-select-event": "^5.5.1",
|
|
58
58
|
"uuid": "^11.1.1",
|
|
59
|
-
"@hyphen/hyphen-design-tokens": "^7.8.
|
|
59
|
+
"@hyphen/hyphen-design-tokens": "^7.8.1"
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
62
|
"@babel/core": "^7.27.4",
|
|
@@ -108,6 +108,7 @@
|
|
|
108
108
|
"react": "^18.3.1",
|
|
109
109
|
"react-dom": "^18.3.1",
|
|
110
110
|
"react-is": "^18.3.1",
|
|
111
|
+
"remark-gfm": "^4.0.1",
|
|
111
112
|
"sass": "^1.77.8",
|
|
112
113
|
"sass-loader": "^13.3.3",
|
|
113
114
|
"size-limit": "^11.2.0",
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
3
|
+
import { expect } from 'storybook/test';
|
|
4
|
+
import { FormControl } from './FormControl';
|
|
5
|
+
|
|
6
|
+
const meta = {
|
|
7
|
+
component: FormControl,
|
|
8
|
+
tags: ['ai-generated'],
|
|
9
|
+
} satisfies Meta<typeof FormControl>;
|
|
10
|
+
|
|
11
|
+
export default meta;
|
|
12
|
+
type Story = StoryObj<typeof meta>;
|
|
13
|
+
|
|
14
|
+
export const Default: Story = {
|
|
15
|
+
args: {
|
|
16
|
+
id: 'first-name',
|
|
17
|
+
label: 'First name',
|
|
18
|
+
},
|
|
19
|
+
render: (args) => (
|
|
20
|
+
<FormControl {...args}>
|
|
21
|
+
<input id={args.id} type="text" />
|
|
22
|
+
</FormControl>
|
|
23
|
+
),
|
|
24
|
+
play: async ({ canvas }) => {
|
|
25
|
+
// Proves the FormLabel htmlFor/input id wiring, not just that it mounted.
|
|
26
|
+
await expect(canvas.getByLabelText('First name')).toBeVisible();
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const WithHelpText: Story = {
|
|
31
|
+
args: {
|
|
32
|
+
id: 'email',
|
|
33
|
+
label: 'Email address',
|
|
34
|
+
helpText: 'We will never share your email.',
|
|
35
|
+
},
|
|
36
|
+
render: (args) => (
|
|
37
|
+
<FormControl {...args}>
|
|
38
|
+
<input id={args.id} type="email" />
|
|
39
|
+
</FormControl>
|
|
40
|
+
),
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const WithError: Story = {
|
|
44
|
+
args: {
|
|
45
|
+
id: 'password',
|
|
46
|
+
label: 'Password',
|
|
47
|
+
error: 'Password must be at least 8 characters.',
|
|
48
|
+
},
|
|
49
|
+
render: (args) => (
|
|
50
|
+
<FormControl {...args}>
|
|
51
|
+
<input id={args.id} type="password" />
|
|
52
|
+
</FormControl>
|
|
53
|
+
),
|
|
54
|
+
play: async ({ canvas }) => {
|
|
55
|
+
// The error prop renders as an InputValidationMessage below the input.
|
|
56
|
+
await expect(
|
|
57
|
+
canvas.getByText('Password must be at least 8 characters.')
|
|
58
|
+
).toBeVisible();
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const Required: Story = {
|
|
63
|
+
args: {
|
|
64
|
+
id: 'username',
|
|
65
|
+
label: 'Username',
|
|
66
|
+
isRequired: true,
|
|
67
|
+
},
|
|
68
|
+
render: (args) => (
|
|
69
|
+
<FormControl {...args}>
|
|
70
|
+
<input id={args.id} type="text" required />
|
|
71
|
+
</FormControl>
|
|
72
|
+
),
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export const HiddenLabel: Story = {
|
|
76
|
+
args: {
|
|
77
|
+
id: 'search',
|
|
78
|
+
label: 'Search',
|
|
79
|
+
hideLabel: true,
|
|
80
|
+
},
|
|
81
|
+
render: (args) => (
|
|
82
|
+
<FormControl {...args}>
|
|
83
|
+
<input id={args.id} type="search" aria-label={args.label} />
|
|
84
|
+
</FormControl>
|
|
85
|
+
),
|
|
86
|
+
play: async ({ canvas }) => {
|
|
87
|
+
// hideLabel suppresses the visible FormLabel entirely.
|
|
88
|
+
await expect(canvas.queryByText('Search')).not.toBeInTheDocument();
|
|
89
|
+
// The input must still be accessible via its aria-label.
|
|
90
|
+
await expect(canvas.getByLabelText('Search')).toBeVisible();
|
|
91
|
+
},
|
|
92
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
3
|
+
import { HelpText } from './HelpText';
|
|
4
|
+
import { Box } from '../Box/Box';
|
|
5
|
+
|
|
6
|
+
const meta = {
|
|
7
|
+
component: HelpText,
|
|
8
|
+
tags: ['ai-generated'],
|
|
9
|
+
} satisfies Meta<typeof HelpText>;
|
|
10
|
+
|
|
11
|
+
export default meta;
|
|
12
|
+
type Story = StoryObj<typeof meta>;
|
|
13
|
+
|
|
14
|
+
export const Default: Story = {
|
|
15
|
+
args: {
|
|
16
|
+
children: 'Additional clarifying text to help describe the input.',
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const BelowAnInput: Story = {
|
|
21
|
+
render: () => (
|
|
22
|
+
<Box gap="2xs">
|
|
23
|
+
<label htmlFor="help-text-example">Email address</label>
|
|
24
|
+
<input
|
|
25
|
+
id="help-text-example"
|
|
26
|
+
type="email"
|
|
27
|
+
aria-describedby="help-text-example-hint"
|
|
28
|
+
/>
|
|
29
|
+
{/* HelpText doesn't forward an id, so associate via a wrapper. */}
|
|
30
|
+
<div id="help-text-example-hint">
|
|
31
|
+
<HelpText>We will only use this to send your receipt.</HelpText>
|
|
32
|
+
</div>
|
|
33
|
+
</Box>
|
|
34
|
+
),
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const WithRichContent: Story = {
|
|
38
|
+
args: {
|
|
39
|
+
children: (
|
|
40
|
+
<>
|
|
41
|
+
Must contain at least <strong>8 characters</strong>.
|
|
42
|
+
</>
|
|
43
|
+
),
|
|
44
|
+
},
|
|
45
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
3
|
+
import { expect } from 'storybook/test';
|
|
4
|
+
import { InputValidationMessage } from './InputValidationMessage';
|
|
5
|
+
import { Box } from '../Box/Box';
|
|
6
|
+
|
|
7
|
+
const meta = {
|
|
8
|
+
component: InputValidationMessage,
|
|
9
|
+
tags: ['ai-generated'],
|
|
10
|
+
} satisfies Meta<typeof InputValidationMessage>;
|
|
11
|
+
|
|
12
|
+
export default meta;
|
|
13
|
+
type Story = StoryObj<typeof meta>;
|
|
14
|
+
|
|
15
|
+
export const Default: Story = {
|
|
16
|
+
args: {
|
|
17
|
+
children: 'This field is required.',
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const Sizes: Story = {
|
|
22
|
+
args: {
|
|
23
|
+
children: 'This field is required.',
|
|
24
|
+
},
|
|
25
|
+
render: () => (
|
|
26
|
+
<Box gap="sm">
|
|
27
|
+
<InputValidationMessage size="xs">
|
|
28
|
+
Extra small validation message
|
|
29
|
+
</InputValidationMessage>
|
|
30
|
+
<InputValidationMessage size="sm">
|
|
31
|
+
Small validation message
|
|
32
|
+
</InputValidationMessage>
|
|
33
|
+
<InputValidationMessage size="md">
|
|
34
|
+
Medium validation message
|
|
35
|
+
</InputValidationMessage>
|
|
36
|
+
</Box>
|
|
37
|
+
),
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const CssCheck: Story = {
|
|
41
|
+
args: {
|
|
42
|
+
children: 'This field is required.',
|
|
43
|
+
},
|
|
44
|
+
play: async ({ canvas }) => {
|
|
45
|
+
const message = canvas.getByText('This field is required.');
|
|
46
|
+
// font-color-danger resolves to --color-font-danger (#dc2626 in the light
|
|
47
|
+
// theme) — fails if the design-token CSS did not load in the preview.
|
|
48
|
+
await expect(getComputedStyle(message).color).toBe('rgb(220, 38, 38)');
|
|
49
|
+
},
|
|
50
|
+
};
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { Canvas, Meta, ArgTypes } from '@storybook/addon-docs/blocks';
|
|
2
|
+
import {
|
|
3
|
+
SidebarProvider,
|
|
4
|
+
Sidebar,
|
|
5
|
+
SidebarTrigger,
|
|
6
|
+
SidebarMenuButton,
|
|
7
|
+
} from './Sidebar';
|
|
8
|
+
import * as Stories from './Sidebar.stories';
|
|
9
|
+
|
|
10
|
+
<Meta of={Stories} />
|
|
11
|
+
|
|
12
|
+
{/*
|
|
13
|
+
The embedded stories render in iframes, and the iframe is the story's
|
|
14
|
+
viewport. Keep it at least as wide as the desktop breakpoint (992px) so
|
|
15
|
+
the examples show the desktop sidebar instead of the mobile drawer,
|
|
16
|
+
scrolling horizontally when the docs column is narrower. Scoped to this
|
|
17
|
+
page's Canvas blocks via the sidebar-docs-canvas class.
|
|
18
|
+
*/}
|
|
19
|
+
|
|
20
|
+
<style>
|
|
21
|
+
{`
|
|
22
|
+
.sidebar-docs-canvas .docs-story { overflow-x: auto; }
|
|
23
|
+
.sidebar-docs-canvas .docs-story iframe { min-width: 1100px; }
|
|
24
|
+
`}
|
|
25
|
+
</style>
|
|
26
|
+
|
|
27
|
+
# Sidebar
|
|
28
|
+
|
|
29
|
+
A composable, collapsible app sidebar for primary navigation. Use it to build an application sidebar with a header, scrollable content, grouped menus, and a footer, all wired to expand/collapse state that persists across visits and adapts to mobile automatically.
|
|
30
|
+
|
|
31
|
+
<Canvas of={Stories.SidebarExample} />
|
|
32
|
+
|
|
33
|
+
## Anatomy
|
|
34
|
+
|
|
35
|
+
The Sidebar is composed from small building blocks. Compose only the pieces you need:
|
|
36
|
+
|
|
37
|
+
| Component | Purpose |
|
|
38
|
+
| ---------------------- | -------------------------------------------------------------------------------------------------- |
|
|
39
|
+
| `SidebarProvider` | Provides sidebar state (left and right) and keyboard shortcuts. Wrap your whole layout in it. |
|
|
40
|
+
| `Sidebar` | The sidebar container itself. Configure `side` and `collapsible` behavior. |
|
|
41
|
+
| `SidebarHeader` | Sticky top region, typically an org/team switcher. |
|
|
42
|
+
| `SidebarContent` | The scrollable middle region of the sidebar. |
|
|
43
|
+
| `SidebarFooter` | Sticky bottom region, typically the user menu. |
|
|
44
|
+
| `SidebarGroup` | A section within `SidebarContent`. |
|
|
45
|
+
| `SidebarGroupLabel` | Label for a group. Hidden automatically when collapsed to icons. |
|
|
46
|
+
| `SidebarMenu` | A menu (`ul`) of navigation items. |
|
|
47
|
+
| `SidebarMenuItem` | A single item (`li`) within a `SidebarMenu`. |
|
|
48
|
+
| `SidebarMenuButton` | The interactive element of an item. Supports `asChild`, `isActive`, and a collapsed-state `tooltip`.|
|
|
49
|
+
| `SidebarMenuAction` | A secondary action button rendered at the edge of a menu item (e.g. a "more" menu). |
|
|
50
|
+
| `SidebarMenuBadge` | A badge (e.g. a count) rendered at the edge of a menu item. |
|
|
51
|
+
| `SidebarMenuSub` | A nested sub-menu, typically inside a `Collapsible`. |
|
|
52
|
+
| `SidebarMenuSubItem` | A single item within a `SidebarMenuSub`. |
|
|
53
|
+
| `SidebarMenuSubButton` | The interactive element of a sub-menu item. Supports `asChild` and `isActive`. |
|
|
54
|
+
| `SidebarTrigger` | A button that toggles the sidebar. Place it in your page content or mobile header. |
|
|
55
|
+
| `SidebarRail` | A slim hover target on the sidebar edge for toggling with the mouse. |
|
|
56
|
+
| `SidebarInset` | The `main` content area that sits alongside the sidebar. |
|
|
57
|
+
| `useSidebar` | Hook for reading and controlling sidebar state from your own components. |
|
|
58
|
+
|
|
59
|
+
A typical app sidebar composition looks like this:
|
|
60
|
+
|
|
61
|
+
```tsx
|
|
62
|
+
<SidebarProvider>
|
|
63
|
+
<Sidebar side="left" collapsible="icon">
|
|
64
|
+
<SidebarHeader>{/* org switcher */}</SidebarHeader>
|
|
65
|
+
<SidebarContent>
|
|
66
|
+
<SidebarGroup>
|
|
67
|
+
<SidebarGroupLabel>Platform</SidebarGroupLabel>
|
|
68
|
+
<SidebarMenu>
|
|
69
|
+
<SidebarMenuItem>
|
|
70
|
+
<SidebarMenuButton asChild isActive tooltip="Dashboard">
|
|
71
|
+
<a href="/">
|
|
72
|
+
<Icon name="dashboard" />
|
|
73
|
+
<span>Dashboard</span>
|
|
74
|
+
</a>
|
|
75
|
+
</SidebarMenuButton>
|
|
76
|
+
</SidebarMenuItem>
|
|
77
|
+
</SidebarMenu>
|
|
78
|
+
</SidebarGroup>
|
|
79
|
+
</SidebarContent>
|
|
80
|
+
<SidebarFooter>{/* user menu */}</SidebarFooter>
|
|
81
|
+
<SidebarRail />
|
|
82
|
+
</Sidebar>
|
|
83
|
+
<SidebarInset>
|
|
84
|
+
<SidebarTrigger />
|
|
85
|
+
{/* page content */}
|
|
86
|
+
</SidebarInset>
|
|
87
|
+
</SidebarProvider>
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Usage Guidelines
|
|
91
|
+
|
|
92
|
+
- Wrap the entire layout — sidebar and page content — in a single `SidebarProvider`. One provider manages both a left and a right sidebar.
|
|
93
|
+
- Wrap your app (or the layout) in `ResponsiveProvider` so `useIsMobile` can detect the viewport; below the `desktop` breakpoint the sidebar renders inside a [Drawer](/docs/components-drawer--docs) and is toggled with `SidebarTrigger`.
|
|
94
|
+
- Use `SidebarMenuButton asChild` to render your router's link component (or an `a` tag) so navigation items are real links.
|
|
95
|
+
- Give menu buttons a `tooltip` so labels remain discoverable when the sidebar is collapsed to icons.
|
|
96
|
+
- Set `isActive` on the menu button for the current route.
|
|
97
|
+
- Include a `SidebarRail` so users can toggle the sidebar by clicking its edge.
|
|
98
|
+
|
|
99
|
+
## Collapsible Behavior
|
|
100
|
+
|
|
101
|
+
The `collapsible` prop on `Sidebar` controls what happens when the sidebar is toggled closed:
|
|
102
|
+
|
|
103
|
+
- `offcanvas` (default) — the sidebar slides fully out of view.
|
|
104
|
+
- `icon` — the sidebar collapses to a narrow icon strip; menu button tooltips appear on hover.
|
|
105
|
+
- `none` — the sidebar cannot collapse and stays at full width.
|
|
106
|
+
|
|
107
|
+
## Right Sidebar
|
|
108
|
+
|
|
109
|
+
Set `side="right"` to render a sidebar on the opposite edge, useful for contextual panels. A right sidebar is wider by default (`24rem` vs `16rem`). Use `SidebarTrigger side="right"` to toggle it from within your content.
|
|
110
|
+
|
|
111
|
+
<Canvas of={Stories.SidebarRightExample} />
|
|
112
|
+
|
|
113
|
+
## Left and Right Together
|
|
114
|
+
|
|
115
|
+
One `SidebarProvider` manages both sides independently. Pass per-side values to `defaultOpen` and `storageKey` as objects with `left` and `right` keys.
|
|
116
|
+
|
|
117
|
+
<Canvas of={Stories.SidebarBothSides} />
|
|
118
|
+
|
|
119
|
+
## Collapsed by Default
|
|
120
|
+
|
|
121
|
+
Pass `defaultOpen={false}` to the provider to start collapsed.
|
|
122
|
+
|
|
123
|
+
<Canvas of={Stories.SidebarCollapsed} />
|
|
124
|
+
|
|
125
|
+
## State Persistence
|
|
126
|
+
|
|
127
|
+
Whenever the user toggles a sidebar, the provider writes the new state to `localStorage` under the resolved `storageKey` (a string key applies to the left sidebar, with `_right` appended for the right sidebar; pass an object to set each side explicitly). Reading the stored value back is left to your app — pass it to `defaultOpen` on mount:
|
|
128
|
+
|
|
129
|
+
```tsx
|
|
130
|
+
// Guard the read so it is safe in SSR environments (e.g. Next.js),
|
|
131
|
+
// where localStorage does not exist on the server.
|
|
132
|
+
const startExpanded =
|
|
133
|
+
typeof window !== 'undefined'
|
|
134
|
+
? localStorage.getItem('sidebar_expanded') !== 'false'
|
|
135
|
+
: true;
|
|
136
|
+
|
|
137
|
+
<SidebarProvider storageKey="sidebar_expanded" defaultOpen={startExpanded}>
|
|
138
|
+
...
|
|
139
|
+
</SidebarProvider>;
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Controlled Sidebar
|
|
143
|
+
|
|
144
|
+
Pass `open` (boolean, or `{ left, right }`) to control the sidebar yourself, and use `onOpenChange(open, side)` to respond to toggle requests. Omit `open` for uncontrolled behavior with `defaultOpen`.
|
|
145
|
+
|
|
146
|
+
## Keyboard Shortcuts
|
|
147
|
+
|
|
148
|
+
- <kbd>[</kbd> toggles the left sidebar.
|
|
149
|
+
- <kbd>]</kbd> toggles the right sidebar.
|
|
150
|
+
|
|
151
|
+
Shortcuts are ignored while focus is in an input, textarea, select, or content-editable element.
|
|
152
|
+
|
|
153
|
+
## useSidebar
|
|
154
|
+
|
|
155
|
+
Call `useSidebar()` inside a `SidebarProvider` to read or control sidebar state from your own components. Inside a `Sidebar` it resolves to that sidebar's side; elsewhere pass the side explicitly: `useSidebar('right')`.
|
|
156
|
+
|
|
157
|
+
It returns:
|
|
158
|
+
|
|
159
|
+
| Property | Type | Description |
|
|
160
|
+
| --------------- | ----------------------------- | -------------------------------------------------- |
|
|
161
|
+
| `state` | `'expanded' \| 'collapsed'` | Current visual state. |
|
|
162
|
+
| `open` | `boolean` | Whether the (desktop) sidebar is open. |
|
|
163
|
+
| `setOpen` | `(open: boolean) => void` | Set the desktop open state. |
|
|
164
|
+
| `openMobile` | `boolean` | Whether the mobile drawer is open. |
|
|
165
|
+
| `setOpenMobile` | `(open: boolean) => void` | Set the mobile drawer state. |
|
|
166
|
+
| `toggleSidebar` | `() => void` | Toggle the sidebar (desktop or mobile as needed). |
|
|
167
|
+
| `isMobile` | `boolean` | Whether the viewport is below the desktop breakpoint. |
|
|
168
|
+
| `side` | `'left' \| 'right'` | Which side this state refers to. |
|
|
169
|
+
|
|
170
|
+
## Props
|
|
171
|
+
|
|
172
|
+
All components forward refs and accept the standard props of the elements they render (`className`, `style`, event handlers, etc.).
|
|
173
|
+
|
|
174
|
+
### SidebarProvider
|
|
175
|
+
|
|
176
|
+
<ArgTypes of={SidebarProvider} />
|
|
177
|
+
|
|
178
|
+
### Sidebar
|
|
179
|
+
|
|
180
|
+
<ArgTypes of={Sidebar} />
|
|
181
|
+
|
|
182
|
+
### SidebarTrigger
|
|
183
|
+
|
|
184
|
+
Extends [Button](/docs/components-button--docs).
|
|
185
|
+
|
|
186
|
+
<ArgTypes of={SidebarTrigger} />
|
|
187
|
+
|
|
188
|
+
### SidebarMenuButton
|
|
189
|
+
|
|
190
|
+
<ArgTypes of={SidebarMenuButton} />
|
|
191
|
+
|
|
192
|
+
## Theming
|
|
193
|
+
|
|
194
|
+
The sidebar exposes CSS custom properties you can override on `SidebarProvider` (via `style`) or a parent element:
|
|
195
|
+
|
|
196
|
+
| Custom Property | Default | Description |
|
|
197
|
+
| ------------------------------ | ---------- | ----------------------------------------------- |
|
|
198
|
+
| `--sidebar-width` | `16rem` | Expanded width (`24rem` for a right sidebar). |
|
|
199
|
+
| `--sidebar-width-icon` | `44px` | Width when collapsed to icons. |
|
|
200
|
+
| `--sidebar-transition-duration`| `200ms` | Expand/collapse animation duration. |
|
|
201
|
+
| `--sidebar-transition-timing` | `linear` | Expand/collapse animation timing function. |
|
|
202
|
+
|
|
203
|
+
## Accessibility
|
|
204
|
+
|
|
205
|
+
- `SidebarTrigger` renders a real button with an `aria-label` of "Toggle left sidebar" / "Toggle right sidebar".
|
|
206
|
+
- `SidebarRail` is removed from the tab order (`tabIndex={-1}`) since `SidebarTrigger` and the keyboard shortcuts cover keyboard users; it exposes an `aria-label` and a `title` with the shortcut.
|
|
207
|
+
- `SidebarMenu` and `SidebarMenuSub` render semantic lists, and `asChild` lets menu items render real links for correct link semantics.
|
|
208
|
+
- On mobile the sidebar renders inside a [Drawer](/docs/components-drawer--docs), which traps focus and closes with <kbd>Esc</kbd>.
|
|
@@ -50,6 +50,19 @@ const meta: Meta<typeof Sidebar> = {
|
|
|
50
50
|
parameters: {
|
|
51
51
|
layout: 'fullscreen',
|
|
52
52
|
overrideDecorator: true,
|
|
53
|
+
// The sidebar examples fill 100svh, so render them in a fixed-height
|
|
54
|
+
// iframe on the docs page instead of inline at full height.
|
|
55
|
+
docs: {
|
|
56
|
+
story: {
|
|
57
|
+
inline: false,
|
|
58
|
+
height: '640px',
|
|
59
|
+
},
|
|
60
|
+
// Scopes the docs-page CSS in Sidebar.mdx (which forces a desktop-width
|
|
61
|
+
// viewport on the story iframes) to this page's Canvas blocks only.
|
|
62
|
+
canvas: {
|
|
63
|
+
className: 'sidebar-docs-canvas',
|
|
64
|
+
},
|
|
65
|
+
},
|
|
53
66
|
chromatic: {
|
|
54
67
|
modes: {
|
|
55
68
|
light: allModes['light'],
|
|
@@ -243,9 +243,26 @@ function useSidebar(sideOverride?: SidebarSide) {
|
|
|
243
243
|
const SidebarProvider = forwardRef<
|
|
244
244
|
HTMLDivElement,
|
|
245
245
|
React.ComponentProps<'div'> & {
|
|
246
|
+
/**
|
|
247
|
+
* Initial open state for uncontrolled usage. Pass a boolean for both
|
|
248
|
+
* sides, or `{ left, right }` to set each side independently.
|
|
249
|
+
*/
|
|
246
250
|
defaultOpen?: SidebarOpenValue;
|
|
251
|
+
/**
|
|
252
|
+
* Controlled open state. Pass a boolean for both sides, or
|
|
253
|
+
* `{ left, right }` to control each side independently.
|
|
254
|
+
*/
|
|
247
255
|
open?: SidebarOpenValue;
|
|
256
|
+
/**
|
|
257
|
+
* localStorage key(s) the open state is written to when a sidebar is
|
|
258
|
+
* toggled. A string applies to the left sidebar, with `_right` appended
|
|
259
|
+
* for the right sidebar; pass `{ left, right }` to set each explicitly.
|
|
260
|
+
*/
|
|
248
261
|
storageKey?: SidebarStorageKey;
|
|
262
|
+
/**
|
|
263
|
+
* Called when a sidebar is toggled, with the new open state and which
|
|
264
|
+
* side was toggled.
|
|
265
|
+
*/
|
|
249
266
|
onOpenChange?: (open: boolean, side?: SidebarSide) => void;
|
|
250
267
|
}
|
|
251
268
|
>(
|
|
@@ -368,7 +385,15 @@ SidebarProvider.displayName = 'SidebarProvider';
|
|
|
368
385
|
const Sidebar = React.forwardRef<
|
|
369
386
|
HTMLDivElement,
|
|
370
387
|
React.ComponentProps<'div'> & {
|
|
388
|
+
/**
|
|
389
|
+
* Which edge of the viewport the sidebar is attached to.
|
|
390
|
+
*/
|
|
371
391
|
side?: 'left' | 'right';
|
|
392
|
+
/**
|
|
393
|
+
* Collapse behavior when the sidebar is toggled closed: slide fully out
|
|
394
|
+
* of view (`offcanvas`), collapse to a narrow icon strip (`icon`), or
|
|
395
|
+
* stay at full width (`none`).
|
|
396
|
+
*/
|
|
372
397
|
collapsible?: 'offcanvas' | 'icon' | 'none';
|
|
373
398
|
}
|
|
374
399
|
>(
|
|
@@ -498,7 +523,14 @@ Sidebar.displayName = 'Sidebar';
|
|
|
498
523
|
const SidebarTrigger = React.forwardRef<
|
|
499
524
|
React.ElementRef<typeof Button>,
|
|
500
525
|
React.ComponentProps<typeof Button> & {
|
|
526
|
+
/**
|
|
527
|
+
* Which sidebar to toggle. Defaults to the side of the containing
|
|
528
|
+
* Sidebar, or `left` outside of one.
|
|
529
|
+
*/
|
|
501
530
|
side?: SidebarSide;
|
|
531
|
+
/**
|
|
532
|
+
* Icon shown on the trigger button.
|
|
533
|
+
*/
|
|
502
534
|
iconName?: IconName;
|
|
503
535
|
}
|
|
504
536
|
>(({ className, onClick, side, iconName = 'dock-left', ...props }, ref) => {
|
|
@@ -642,8 +674,18 @@ SidebarMenuItem.displayName = 'SidebarMenuItem';
|
|
|
642
674
|
const SidebarMenuButton = React.forwardRef<
|
|
643
675
|
HTMLButtonElement,
|
|
644
676
|
React.ComponentProps<'button'> & {
|
|
677
|
+
/**
|
|
678
|
+
* Render the child element (e.g. a router link) instead of a `button`.
|
|
679
|
+
*/
|
|
645
680
|
asChild?: boolean;
|
|
681
|
+
/**
|
|
682
|
+
* Marks the item as the current page.
|
|
683
|
+
*/
|
|
646
684
|
isActive?: boolean;
|
|
685
|
+
/**
|
|
686
|
+
* Tooltip shown when the sidebar is collapsed to icons (desktop only).
|
|
687
|
+
* Pass a string, or TooltipContent props for full control.
|
|
688
|
+
*/
|
|
647
689
|
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
|
|
648
690
|
}
|
|
649
691
|
>(
|