@shadow-js/ui 0.0.0-stage → 1.0.0
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/CHANGELOG.md +24 -0
- package/LICENSE +21 -0
- package/README.md +598 -2
- package/dist/cli.mjs +2293 -0
- package/dist/dev/cli/schema.d.ts +73 -0
- package/dist/dev/index.d.ts +6 -0
- package/dist/dev/index.js +5 -0
- package/dist/dev/locales/ar.d.ts +141 -0
- package/dist/dev/locales/ar.js +128 -0
- package/dist/dev/locales/ar.js.map +1 -0
- package/dist/dev/locales/en.d.ts +141 -0
- package/dist/dev/locales/en.js +128 -0
- package/dist/dev/locales/en.js.map +1 -0
- package/dist/dev/registry/utils/utils.d.ts +29 -0
- package/dist/dev/registry/utils/utils.js +27 -0
- package/dist/dev/registry/utils/utils.js.map +1 -0
- package/dist/dev/theme/theme-logic.d.ts +187 -0
- package/dist/dev/theme/theme-logic.js +184 -0
- package/dist/dev/theme/theme-logic.js.map +1 -0
- package/dist/dev/theme/theme-provider.d.ts +90 -0
- package/dist/dev/theme/theme-provider.js +150 -0
- package/dist/dev/theme/theme-provider.js.map +1 -0
- package/dist/prod/index.js +1 -0
- package/dist/prod/locales/ar.js +2 -0
- package/dist/prod/locales/ar.js.map +1 -0
- package/dist/prod/locales/en.js +2 -0
- package/dist/prod/locales/en.js.map +1 -0
- package/dist/prod/registry/utils/utils.js +2 -0
- package/dist/prod/registry/utils/utils.js.map +1 -0
- package/dist/prod/theme/theme-logic.js +2 -0
- package/dist/prod/theme/theme-logic.js.map +1 -0
- package/dist/prod/theme/theme-provider.js +2 -0
- package/dist/prod/theme/theme-provider.js.map +1 -0
- package/package.json +102 -4
- package/registry/accordion/accordion.tsx +210 -0
- package/registry/accordion/item.json +4 -0
- package/registry/alert/alert.tsx +200 -0
- package/registry/alert/item.json +4 -0
- package/registry/alert-dialog/alert-dialog.tsx +530 -0
- package/registry/alert-dialog/item.json +4 -0
- package/registry/aspect-ratio/aspect-ratio.tsx +71 -0
- package/registry/aspect-ratio/item.json +4 -0
- package/registry/attachment/attachment.tsx +459 -0
- package/registry/attachment/item.json +4 -0
- package/registry/avatar/avatar.tsx +385 -0
- package/registry/avatar/item.json +4 -0
- package/registry/badge/badge.tsx +108 -0
- package/registry/badge/item.json +4 -0
- package/registry/breadcrumb/breadcrumb.tsx +354 -0
- package/registry/breadcrumb/item.json +4 -0
- package/registry/bubble/bubble.tsx +260 -0
- package/registry/bubble/item.json +4 -0
- package/registry/button/button.tsx +125 -0
- package/registry/button/item.json +4 -0
- package/registry/button-group/button-group.tsx +189 -0
- package/registry/button-group/item.json +4 -0
- package/registry/calendar/calendar.tsx +348 -0
- package/registry/calendar/item.json +4 -0
- package/registry/card/card.tsx +311 -0
- package/registry/card/item.json +4 -0
- package/registry/carousel/carousel.tsx +851 -0
- package/registry/carousel/item.json +4 -0
- package/registry/chart/chart.tsx +1956 -0
- package/registry/chart/item.json +4 -0
- package/registry/chart-scale/chart-scale.ts +670 -0
- package/registry/chart-scale/item.json +5 -0
- package/registry/checkbox/checkbox.tsx +79 -0
- package/registry/checkbox/item.json +4 -0
- package/registry/code-block/code-block.tsx +470 -0
- package/registry/code-block/item.json +4 -0
- package/registry/collapsible/collapsible.tsx +117 -0
- package/registry/collapsible/item.json +4 -0
- package/registry/combobox/combobox.tsx +977 -0
- package/registry/combobox/item.json +4 -0
- package/registry/command/command.tsx +757 -0
- package/registry/command/item.json +4 -0
- package/registry/context-menu/context-menu.tsx +697 -0
- package/registry/context-menu/item.json +4 -0
- package/registry/data-table/data-table.tsx +437 -0
- package/registry/data-table/item.json +4 -0
- package/registry/date-picker/date-picker.tsx +349 -0
- package/registry/date-picker/item.json +4 -0
- package/registry/dialog/dialog.tsx +412 -0
- package/registry/dialog/item.json +4 -0
- package/registry/direction/direction.tsx +88 -0
- package/registry/direction/item.json +4 -0
- package/registry/drawer/drawer.tsx +787 -0
- package/registry/drawer/item.json +4 -0
- package/registry/dropdown-menu/dropdown-menu.tsx +692 -0
- package/registry/dropdown-menu/item.json +4 -0
- package/registry/empty/empty.tsx +240 -0
- package/registry/empty/item.json +4 -0
- package/registry/field/field.tsx +624 -0
- package/registry/field/item.json +4 -0
- package/registry/form/form.tsx +1112 -0
- package/registry/form/item.json +4 -0
- package/registry/hover-card/hover-card.tsx +144 -0
- package/registry/hover-card/item.json +4 -0
- package/registry/icons/icons.tsx +1691 -0
- package/registry/icons/item.json +4 -0
- package/registry/input/input.tsx +43 -0
- package/registry/input/item.json +4 -0
- package/registry/input-group/input-group.tsx +315 -0
- package/registry/input-group/item.json +4 -0
- package/registry/input-otp/input-otp.tsx +329 -0
- package/registry/input-otp/item.json +4 -0
- package/registry/item/item.json +4 -0
- package/registry/item/item.tsx +439 -0
- package/registry/kbd/item.json +4 -0
- package/registry/kbd/kbd.tsx +65 -0
- package/registry/label/item.json +4 -0
- package/registry/label/label.tsx +60 -0
- package/registry/marker/item.json +4 -0
- package/registry/marker/marker.tsx +175 -0
- package/registry/menubar/item.json +4 -0
- package/registry/menubar/menubar.tsx +694 -0
- package/registry/message/item.json +4 -0
- package/registry/message/message.tsx +280 -0
- package/registry/message-scroller/item.json +4 -0
- package/registry/message-scroller/message-scroller.tsx +262 -0
- package/registry/native-select/item.json +4 -0
- package/registry/native-select/native-select.tsx +113 -0
- package/registry/navigation-menu/item.json +4 -0
- package/registry/navigation-menu/navigation-menu.tsx +362 -0
- package/registry/pagination/item.json +4 -0
- package/registry/pagination/pagination.tsx +460 -0
- package/registry/password-input/item.json +5 -0
- package/registry/password-input/password-input.tsx +494 -0
- package/registry/popover/item.json +4 -0
- package/registry/popover/popover.tsx +284 -0
- package/registry/progress/item.json +4 -0
- package/registry/progress/progress.tsx +190 -0
- package/registry/questionnaire/item.json +4 -0
- package/registry/questionnaire/questionnaire.tsx +569 -0
- package/registry/radio-group/item.json +4 -0
- package/registry/radio-group/radio-group.tsx +101 -0
- package/registry/resizable/item.json +4 -0
- package/registry/resizable/resizable.tsx +455 -0
- package/registry/scroll-area/item.json +4 -0
- package/registry/scroll-area/scroll-area.tsx +149 -0
- package/registry/select/item.json +4 -0
- package/registry/select/select.tsx +571 -0
- package/registry/separator/item.json +4 -0
- package/registry/separator/separator.tsx +52 -0
- package/registry/sheet/item.json +4 -0
- package/registry/sheet/sheet.tsx +476 -0
- package/registry/sidebar/item.json +4 -0
- package/registry/sidebar/sidebar.tsx +1870 -0
- package/registry/skeleton/item.json +4 -0
- package/registry/skeleton/skeleton.tsx +47 -0
- package/registry/slider/item.json +4 -0
- package/registry/slider/slider.tsx +149 -0
- package/registry/spinner/item.json +4 -0
- package/registry/spinner/spinner.tsx +51 -0
- package/registry/switch/item.json +4 -0
- package/registry/switch/switch.tsx +93 -0
- package/registry/table/item.json +4 -0
- package/registry/table/table.tsx +373 -0
- package/registry/tabs/item.json +4 -0
- package/registry/tabs/tabs.tsx +209 -0
- package/registry/textarea/item.json +4 -0
- package/registry/textarea/textarea.tsx +51 -0
- package/registry/toast/item.json +4 -0
- package/registry/toast/toast.tsx +949 -0
- package/registry/toggle/item.json +4 -0
- package/registry/toggle/toggle.tsx +110 -0
- package/registry/toggle-group/item.json +4 -0
- package/registry/toggle-group/toggle-group.tsx +189 -0
- package/registry/tooltip/item.json +4 -0
- package/registry/tooltip/tooltip.tsx +169 -0
- package/registry/trace/item.json +4 -0
- package/registry/trace/trace.tsx +356 -0
- package/registry/ui-strings/item.json +5 -0
- package/registry/ui-strings/ui-strings.tsx +454 -0
- package/registry/use-highlighted-code/item.json +5 -0
- package/registry/use-highlighted-code/use-highlighted-code.tsx +145 -0
- package/registry/use-mobile/item.json +5 -0
- package/registry/use-mobile/use-mobile.ts +46 -0
- package/registry/utils/item.json +5 -0
- package/registry/utils/utils.ts +38 -0
- package/registry.json +3274 -0
- package/styles/animations.css +321 -0
- package/styles/theme-core.css +55 -0
- package/styles/theme.css +12 -0
- package/styles/themes/gray.css +69 -0
- package/styles/themes/neutral.css +69 -0
- package/styles/themes/slate.css +69 -0
- package/styles/themes/stone.css +69 -0
- package/styles/themes/zinc.css +69 -0
- package/styles/typeset.css +251 -0
|
@@ -0,0 +1,1870 @@
|
|
|
1
|
+
import { type Accessor, onMount, splitProps, untrack, useEffect, useSignal } from "@shadow-js/core";
|
|
2
|
+
import {
|
|
3
|
+
callHandler,
|
|
4
|
+
createPartContext,
|
|
5
|
+
Polymorphic,
|
|
6
|
+
type PolymorphicProps,
|
|
7
|
+
type PolymorphicTarget,
|
|
8
|
+
type SeparatorProps,
|
|
9
|
+
type TooltipContentProps,
|
|
10
|
+
useBaseId,
|
|
11
|
+
useControllableState,
|
|
12
|
+
useDirectionAround,
|
|
13
|
+
useRefSignal,
|
|
14
|
+
} from "@shadow-js/primitives";
|
|
15
|
+
import type { ComponentProps, JSX } from "@shadow-js/web";
|
|
16
|
+
import { Show } from "@shadow-js/web";
|
|
17
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
18
|
+
import { Button, type ButtonProps } from "@/components/ui/button";
|
|
19
|
+
import { PanelLeftIcon } from "@/components/ui/icons";
|
|
20
|
+
import { Input } from "@/components/ui/input";
|
|
21
|
+
import { Separator } from "@/components/ui/separator";
|
|
22
|
+
import {
|
|
23
|
+
Sheet,
|
|
24
|
+
SheetContent,
|
|
25
|
+
SheetDescription,
|
|
26
|
+
SheetHeader,
|
|
27
|
+
SheetTitle,
|
|
28
|
+
} from "@/components/ui/sheet";
|
|
29
|
+
import { Skeleton } from "@/components/ui/skeleton";
|
|
30
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
|
31
|
+
import { useIsMobile } from "@/hooks/use-mobile";
|
|
32
|
+
import { useUiStrings } from "@/lib/ui-strings";
|
|
33
|
+
import { type ClassValue, cn } from "@/lib/utils";
|
|
34
|
+
|
|
35
|
+
const SIDEBAR_WIDTH = "16rem";
|
|
36
|
+
const SIDEBAR_WIDTH_MOBILE = "18rem";
|
|
37
|
+
const SIDEBAR_WIDTH_ICON = "3rem";
|
|
38
|
+
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
|
|
39
|
+
/** The name of the cookie shadcn/ui keeps the state in. @internal */
|
|
40
|
+
const DEFAULT_COOKIE_NAME = "sidebar_state";
|
|
41
|
+
/** A week, in seconds: how long shadcn/ui keeps the cookie. @internal */
|
|
42
|
+
const DEFAULT_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
|
43
|
+
/** A cookie name the browser accepts: an HTTP token. @internal */
|
|
44
|
+
const COOKIE_NAME = /^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/;
|
|
45
|
+
|
|
46
|
+
/** The props as the implementation sees them: the typed own props plus whatever the target takes. */
|
|
47
|
+
type Loose = {
|
|
48
|
+
readonly as?: PolymorphicTarget;
|
|
49
|
+
readonly class?: ClassValue;
|
|
50
|
+
readonly [prop: string]: unknown;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* What {@link useSidebar} returns. Every state is an accessor.
|
|
55
|
+
* @since 1.0.0
|
|
56
|
+
* @see {@link useSidebar}
|
|
57
|
+
*/
|
|
58
|
+
export interface SidebarContextValue {
|
|
59
|
+
/** `"expanded"` or `"collapsed"` (the desktop sidebar). */
|
|
60
|
+
readonly state: Accessor<"expanded" | "collapsed">;
|
|
61
|
+
/** Whether the desktop sidebar is open. */
|
|
62
|
+
readonly open: Accessor<boolean>;
|
|
63
|
+
/** Opens or closes the desktop sidebar. */
|
|
64
|
+
readonly setOpen: (open: boolean) => void;
|
|
65
|
+
/** Whether the mobile sheet is open. */
|
|
66
|
+
readonly openMobile: Accessor<boolean>;
|
|
67
|
+
/** Opens or closes the mobile sheet. */
|
|
68
|
+
readonly setOpenMobile: (open: boolean) => void;
|
|
69
|
+
/** Whether the sidebar renders as a sheet (narrow viewport, or the provider's `isMobile`). */
|
|
70
|
+
readonly isMobile: Accessor<boolean>;
|
|
71
|
+
/** Toggles the mobile sheet on mobile and the desktop sidebar otherwise. */
|
|
72
|
+
readonly toggleSidebar: () => void;
|
|
73
|
+
/** The reading direction around the sidebar: a `dir` attribute on or above the provider, else the document's. */
|
|
74
|
+
readonly dir: Accessor<"ltr" | "rtl">;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const sidebarContext = createPartContext<SidebarContextValue>("SidebarProvider");
|
|
78
|
+
|
|
79
|
+
/** The edge the nearest `Sidebar` sits on, for parts that point away from it (tooltips). */
|
|
80
|
+
const sidebarSideContext = createPartContext<Accessor<"start" | "end">>("Sidebar");
|
|
81
|
+
|
|
82
|
+
/** Whether the nearest `Sidebar` is collapsed out of view, so its content is not reachable. @internal */
|
|
83
|
+
const sidebarHiddenContext = createPartContext<Accessor<boolean>>("Sidebar");
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The state and actions of the nearest {@link SidebarProvider}: the desktop open state, the mobile
|
|
87
|
+
* sheet state, whether the layout is mobile, and a toggle. Use it from any part inside the provider.
|
|
88
|
+
*
|
|
89
|
+
* @returns The sidebar context: accessors for `state`, `open`, `openMobile` and `isMobile`, and the
|
|
90
|
+
* setters and `toggleSidebar`.
|
|
91
|
+
* @throws When called outside a {@link SidebarProvider}.
|
|
92
|
+
* @example
|
|
93
|
+
* ```tsx
|
|
94
|
+
* import { SidebarProvider, useSidebar } from "@/components/ui/sidebar";
|
|
95
|
+
*
|
|
96
|
+
* function ToggleButton() {
|
|
97
|
+
* const sidebar = useSidebar();
|
|
98
|
+
* return (
|
|
99
|
+
* <button type="button" onClick={() => sidebar.toggleSidebar()}>
|
|
100
|
+
* {sidebar.state()}
|
|
101
|
+
* </button>
|
|
102
|
+
* );
|
|
103
|
+
* }
|
|
104
|
+
*
|
|
105
|
+
* export function App() {
|
|
106
|
+
* return (
|
|
107
|
+
* <SidebarProvider>
|
|
108
|
+
* <ToggleButton />
|
|
109
|
+
* </SidebarProvider>
|
|
110
|
+
* );
|
|
111
|
+
* }
|
|
112
|
+
* ```
|
|
113
|
+
* @since 1.0.0
|
|
114
|
+
* @see {@link SidebarProvider}
|
|
115
|
+
*/
|
|
116
|
+
function useSidebar(): SidebarContextValue {
|
|
117
|
+
return sidebarContext.use("useSidebar");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The stored open state, or `undefined` when nothing usable is stored or storage is blocked. */
|
|
121
|
+
function readStored(key: string): boolean | undefined {
|
|
122
|
+
try {
|
|
123
|
+
const value = localStorage.getItem(key);
|
|
124
|
+
return value === "true" ? true : value === "false" ? false : undefined;
|
|
125
|
+
} catch (error) {
|
|
126
|
+
// Storage is blocked (private mode, sandboxed frame): the sidebar still works, unsaved.
|
|
127
|
+
globalThis.reportError?.(error);
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function writeStored(key: string, open: boolean): void {
|
|
133
|
+
try {
|
|
134
|
+
localStorage.setItem(key, String(open));
|
|
135
|
+
} catch (error) {
|
|
136
|
+
globalThis.reportError?.(error);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The name of the cookie `SidebarProvider` keeps the state in when you give it `cookieName="sidebar_state"`
|
|
142
|
+
* (the name shadcn/ui uses), and the default name of {@link sidebarCookie} and {@link readSidebarCookie}.
|
|
143
|
+
*
|
|
144
|
+
* @example
|
|
145
|
+
* import { SIDEBAR_COOKIE_NAME } from "@/components/ui/sidebar";
|
|
146
|
+
*
|
|
147
|
+
* export const name: string = SIDEBAR_COOKIE_NAME; // "sidebar_state"
|
|
148
|
+
* @see {@link sidebarCookie}
|
|
149
|
+
* @since 1.0.0
|
|
150
|
+
*/
|
|
151
|
+
const SIDEBAR_COOKIE_NAME: string = DEFAULT_COOKIE_NAME;
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Options of {@link sidebarCookie}.
|
|
155
|
+
* @see {@link sidebarCookie}
|
|
156
|
+
* @since 1.0.0
|
|
157
|
+
*/
|
|
158
|
+
export interface SidebarCookieOptions {
|
|
159
|
+
/**
|
|
160
|
+
* The cookie name; letters, digits and the characters `!#$%&'*+.^_`|~-`.
|
|
161
|
+
* @default "sidebar_state"
|
|
162
|
+
*/
|
|
163
|
+
readonly name?: string | undefined;
|
|
164
|
+
/**
|
|
165
|
+
* How long the browser keeps the cookie, in seconds.
|
|
166
|
+
* @default 604800 (a week)
|
|
167
|
+
*/
|
|
168
|
+
readonly maxAge?: number | undefined;
|
|
169
|
+
/**
|
|
170
|
+
* The path the cookie is sent for; no spaces, semicolons or line breaks.
|
|
171
|
+
* @default "/"
|
|
172
|
+
*/
|
|
173
|
+
readonly path?: string | undefined;
|
|
174
|
+
/**
|
|
175
|
+
* Adds `Secure`, so the cookie is only sent over HTTPS.
|
|
176
|
+
* @default false
|
|
177
|
+
*/
|
|
178
|
+
readonly secure?: boolean | undefined;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The text of the cookie that remembers whether the sidebar is open, to assign to `document.cookie` or to
|
|
183
|
+
* send as a `Set-Cookie` header. It has `path`, `max-age` and `SameSite=Lax`, as shadcn/ui's cookie does,
|
|
184
|
+
* and `Secure` on request. Read it back on the server with {@link readSidebarCookie} so the first page
|
|
185
|
+
* already has the right state.
|
|
186
|
+
*
|
|
187
|
+
* @param open Whether the sidebar is open.
|
|
188
|
+
* @param options The cookie name, age, path and `Secure`: see {@link SidebarCookieOptions}.
|
|
189
|
+
* @returns The cookie text, such as `sidebar_state=true; path=/; max-age=604800; SameSite=Lax`.
|
|
190
|
+
* @throws TypeError when the name, the path or the age is not valid in a cookie.
|
|
191
|
+
* @example
|
|
192
|
+
* import { sidebarCookie } from "@/components/ui/sidebar";
|
|
193
|
+
*
|
|
194
|
+
* export const header = sidebarCookie(false, { secure: true });
|
|
195
|
+
* // "sidebar_state=false; path=/; max-age=604800; SameSite=Lax; Secure"
|
|
196
|
+
* @see {@link readSidebarCookie}
|
|
197
|
+
* @since 1.0.0
|
|
198
|
+
*/
|
|
199
|
+
function sidebarCookie(open: boolean, options: SidebarCookieOptions = {}): string {
|
|
200
|
+
const name = options.name ?? DEFAULT_COOKIE_NAME;
|
|
201
|
+
const path = options.path ?? "/";
|
|
202
|
+
const maxAge = options.maxAge ?? DEFAULT_COOKIE_MAX_AGE;
|
|
203
|
+
if (!COOKIE_NAME.test(name)) throw new TypeError(`"${name}" is not a valid cookie name`);
|
|
204
|
+
if (!/^[^\s;]+$/.test(path)) throw new TypeError(`"${path}" is not a valid cookie path`);
|
|
205
|
+
if (!Number.isInteger(maxAge) || maxAge < 0) {
|
|
206
|
+
throw new TypeError(`${maxAge} is not a valid cookie age`);
|
|
207
|
+
}
|
|
208
|
+
const secure = options.secure === true ? "; Secure" : "";
|
|
209
|
+
return `${name}=${open}; path=${path}; max-age=${maxAge}; SameSite=Lax${secure}`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Reads the open state from a `Cookie` header or from `document.cookie`. Use it where the page is first
|
|
214
|
+
* rendered, to start the sidebar in the state the visitor left it in, so there is no jump after load.
|
|
215
|
+
*
|
|
216
|
+
* @param cookies The cookie text: the `Cookie` request header, or `document.cookie`.
|
|
217
|
+
* @param name The cookie name (default `"sidebar_state"`).
|
|
218
|
+
* @returns `true` or `false`, or `undefined` when the cookie is missing or holds something else.
|
|
219
|
+
* @example
|
|
220
|
+
* import { readSidebarCookie } from "@/components/ui/sidebar";
|
|
221
|
+
*
|
|
222
|
+
* export function initialOpen(cookieHeader: string | null): boolean {
|
|
223
|
+
* return readSidebarCookie(cookieHeader) ?? true;
|
|
224
|
+
* }
|
|
225
|
+
* @see {@link sidebarCookie}
|
|
226
|
+
* @since 1.0.0
|
|
227
|
+
*/
|
|
228
|
+
function readSidebarCookie(
|
|
229
|
+
cookies: string | null | undefined,
|
|
230
|
+
name: string = DEFAULT_COOKIE_NAME,
|
|
231
|
+
): boolean | undefined {
|
|
232
|
+
if (cookies === null || cookies === undefined) return undefined;
|
|
233
|
+
for (const part of cookies.split(";")) {
|
|
234
|
+
const separator = part.indexOf("=");
|
|
235
|
+
if (separator < 0 || part.slice(0, separator).trim() !== name) continue;
|
|
236
|
+
const value = part.slice(separator + 1).trim();
|
|
237
|
+
return value === "true" ? true : value === "false" ? false : undefined;
|
|
238
|
+
}
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** The open state in the page's cookies, or `undefined`. A blocked cookie jar is reported, not thrown. @internal */
|
|
243
|
+
function readCookieState(name: string): boolean | undefined {
|
|
244
|
+
try {
|
|
245
|
+
return readSidebarCookie(document.cookie, name);
|
|
246
|
+
} catch (error) {
|
|
247
|
+
globalThis.reportError?.(error);
|
|
248
|
+
return undefined;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function writeCookieState(name: string, open: boolean, maxAge: number | undefined): void {
|
|
253
|
+
try {
|
|
254
|
+
// biome-ignore lint/suspicious/noDocumentCookie: the synchronous cookie jar is the one every browser has and the one a Set-Cookie header matches; the async Cookie Store API is not available everywhere
|
|
255
|
+
document.cookie = sidebarCookie(open, {
|
|
256
|
+
name,
|
|
257
|
+
maxAge,
|
|
258
|
+
secure: globalThis.location?.protocol === "https:",
|
|
259
|
+
});
|
|
260
|
+
} catch (error) {
|
|
261
|
+
globalThis.reportError?.(error);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Props of {@link SidebarProvider}: the attributes of a `div` plus the sidebar state.
|
|
267
|
+
* @since 1.0.0
|
|
268
|
+
* @see {@link SidebarProvider}
|
|
269
|
+
*/
|
|
270
|
+
export type SidebarProviderProps = ComponentProps<"div"> & {
|
|
271
|
+
/**
|
|
272
|
+
* Initial open state of the desktop sidebar when uncontrolled.
|
|
273
|
+
* @default true
|
|
274
|
+
*/
|
|
275
|
+
readonly defaultOpen?: boolean | undefined;
|
|
276
|
+
/** Controlled open state of the desktop sidebar. */
|
|
277
|
+
readonly open?: boolean | undefined;
|
|
278
|
+
/** Called when the desktop sidebar opens or closes. */
|
|
279
|
+
readonly onOpenChange?: ((open: boolean) => void) | undefined;
|
|
280
|
+
/**
|
|
281
|
+
* The `localStorage` key under which an uncontrolled sidebar remembers its open state. The stored
|
|
282
|
+
* value is read after the first render, so the server markup and hydration are not affected.
|
|
283
|
+
*/
|
|
284
|
+
readonly storageKey?: string | undefined;
|
|
285
|
+
/**
|
|
286
|
+
* The name of a cookie that remembers the open state of an uncontrolled sidebar (shadcn/ui uses
|
|
287
|
+
* `"sidebar_state"`). A server can read it with {@link readSidebarCookie} and pass the result as
|
|
288
|
+
* `defaultOpen`, so the first page is already right. The cookie is written only when you ask for it. The
|
|
289
|
+
* stored value is also read after the first render.
|
|
290
|
+
* @default none (no cookie)
|
|
291
|
+
*/
|
|
292
|
+
readonly cookieName?: string | undefined;
|
|
293
|
+
/**
|
|
294
|
+
* How long the browser keeps the cookie, in seconds.
|
|
295
|
+
* @default 604800 (a week)
|
|
296
|
+
*/
|
|
297
|
+
readonly cookieMaxAge?: number | undefined;
|
|
298
|
+
/** Forces the mobile (sheet) or the desktop layout instead of following the viewport width. */
|
|
299
|
+
readonly isMobile?: boolean | undefined;
|
|
300
|
+
/**
|
|
301
|
+
* The letter that toggles the sidebar together with Ctrl or Cmd. Pass `false` to turn the
|
|
302
|
+
* shortcut off.
|
|
303
|
+
* @default "b"
|
|
304
|
+
*/
|
|
305
|
+
readonly keyboardShortcut?: string | false | undefined;
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* The state provider of the sidebar. It holds the open state, sets the width variables and toggles
|
|
310
|
+
* the sidebar with Ctrl+B (or Cmd+B). Wrap the sidebar and the page content in it. It also provides
|
|
311
|
+
* the tooltip delay that the collapsed menu buttons use.
|
|
312
|
+
*
|
|
313
|
+
* Keyboard and ARIA: Ctrl+B or Cmd+B toggles the sidebar, on a mobile layout it toggles the sheet. The
|
|
314
|
+
* shortcut is handled on the window and prevents the browser's own Ctrl+B. It also works while a text
|
|
315
|
+
* field has focus. The provider renders a `div` with no role.
|
|
316
|
+
*
|
|
317
|
+
* shadcn/ui differences: shadcn/ui always keeps the state in the `sidebar_state` cookie. Here it is
|
|
318
|
+
* opt-in: `cookieName` keeps it in a cookie (with {@link sidebarCookie} and {@link readSidebarCookie} for a
|
|
319
|
+
* server), `storageKey` in `localStorage`. `keyboardShortcut` chooses the letter, or turns it off. `class`
|
|
320
|
+
* replaces `className`.
|
|
321
|
+
*
|
|
322
|
+
* @param props - The props, see {@link SidebarProviderProps}.
|
|
323
|
+
* @returns The provider `div` with the children inside.
|
|
324
|
+
* @example
|
|
325
|
+
* ```tsx
|
|
326
|
+
* import {
|
|
327
|
+
* Sidebar,
|
|
328
|
+
* SidebarContent,
|
|
329
|
+
* SidebarInset,
|
|
330
|
+
* SidebarProvider,
|
|
331
|
+
* SidebarTrigger,
|
|
332
|
+
* } from "@/components/ui/sidebar";
|
|
333
|
+
*
|
|
334
|
+
* export function AppShell() {
|
|
335
|
+
* return (
|
|
336
|
+
* <SidebarProvider>
|
|
337
|
+
* <Sidebar>
|
|
338
|
+
* <SidebarContent>Navigation</SidebarContent>
|
|
339
|
+
* </Sidebar>
|
|
340
|
+
* <SidebarInset>
|
|
341
|
+
* <header>
|
|
342
|
+
* <SidebarTrigger />
|
|
343
|
+
* </header>
|
|
344
|
+
* </SidebarInset>
|
|
345
|
+
* </SidebarProvider>
|
|
346
|
+
* );
|
|
347
|
+
* }
|
|
348
|
+
* ```
|
|
349
|
+
* @since 1.0.0
|
|
350
|
+
* @see {@link useSidebar}
|
|
351
|
+
*/
|
|
352
|
+
function SidebarProvider(props: SidebarProviderProps): JSX.Element {
|
|
353
|
+
const [local, rest] = splitProps(props, [
|
|
354
|
+
"defaultOpen",
|
|
355
|
+
"open",
|
|
356
|
+
"onOpenChange",
|
|
357
|
+
"storageKey",
|
|
358
|
+
"cookieName",
|
|
359
|
+
"cookieMaxAge",
|
|
360
|
+
"isMobile",
|
|
361
|
+
"keyboardShortcut",
|
|
362
|
+
"class",
|
|
363
|
+
"style",
|
|
364
|
+
"children",
|
|
365
|
+
]);
|
|
366
|
+
const detected = useIsMobile();
|
|
367
|
+
const isMobile = (): boolean => local.isMobile ?? detected();
|
|
368
|
+
// The wrapper is where a dir attribute is looked for; it is known once the provider has mounted.
|
|
369
|
+
const [wrapperElement, setWrapperElement] = useRefSignal<HTMLElement>();
|
|
370
|
+
let wrapperNode: HTMLElement | undefined;
|
|
371
|
+
const dir = useDirectionAround(undefined, wrapperElement);
|
|
372
|
+
const [openMobile, setOpenMobile] = useSignal(false);
|
|
373
|
+
const [open, setOpenState] = useControllableState<boolean>({
|
|
374
|
+
value: () => local.open,
|
|
375
|
+
defaultValue: () => local.defaultOpen !== false,
|
|
376
|
+
onChange: () => (next) => {
|
|
377
|
+
local.onOpenChange?.(next);
|
|
378
|
+
const key = local.storageKey;
|
|
379
|
+
if (key !== undefined) writeStored(key, next);
|
|
380
|
+
const cookie = local.cookieName;
|
|
381
|
+
if (cookie !== undefined) writeCookieState(cookie, next, local.cookieMaxAge);
|
|
382
|
+
},
|
|
383
|
+
name: "Sidebar open",
|
|
384
|
+
});
|
|
385
|
+
const setOpen = (next: boolean): void => setOpenState(next);
|
|
386
|
+
const toggleSidebar = (): void => {
|
|
387
|
+
if (untrack(isMobile)) setOpenMobile((current) => !current);
|
|
388
|
+
else setOpen(!untrack(open));
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
onMount(() => {
|
|
392
|
+
setWrapperElement(wrapperNode);
|
|
393
|
+
if (local.open === undefined) {
|
|
394
|
+
const cookie = local.cookieName;
|
|
395
|
+
const fromCookie = cookie === undefined ? undefined : readCookieState(cookie);
|
|
396
|
+
const key = local.storageKey;
|
|
397
|
+
const stored = fromCookie ?? (key === undefined ? undefined : readStored(key));
|
|
398
|
+
if (stored !== undefined) setOpen(stored);
|
|
399
|
+
}
|
|
400
|
+
const onKeyDown = (event: KeyboardEvent): void => {
|
|
401
|
+
const shortcut = local.keyboardShortcut ?? SIDEBAR_KEYBOARD_SHORTCUT;
|
|
402
|
+
if (shortcut === false) return;
|
|
403
|
+
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === shortcut.toLowerCase()) {
|
|
404
|
+
event.preventDefault();
|
|
405
|
+
toggleSidebar();
|
|
406
|
+
}
|
|
407
|
+
};
|
|
408
|
+
window.addEventListener("keydown", onKeyDown);
|
|
409
|
+
return () => window.removeEventListener("keydown", onKeyDown);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
const context: SidebarContextValue = {
|
|
413
|
+
state: () => (open() ? "expanded" : "collapsed"),
|
|
414
|
+
open,
|
|
415
|
+
setOpen,
|
|
416
|
+
openMobile,
|
|
417
|
+
setOpenMobile: (next) => {
|
|
418
|
+
setOpenMobile(next);
|
|
419
|
+
},
|
|
420
|
+
isMobile,
|
|
421
|
+
toggleSidebar,
|
|
422
|
+
dir,
|
|
423
|
+
};
|
|
424
|
+
const widths = `--sidebar-width:${SIDEBAR_WIDTH};--sidebar-width-icon:${SIDEBAR_WIDTH_ICON}`;
|
|
425
|
+
return (
|
|
426
|
+
<sidebarContext.Provider value={context}>
|
|
427
|
+
<TooltipProvider delayDuration={0}>
|
|
428
|
+
<div
|
|
429
|
+
ref={(node: HTMLElement) => {
|
|
430
|
+
wrapperNode = node;
|
|
431
|
+
}}
|
|
432
|
+
data-slot="sidebar-wrapper"
|
|
433
|
+
style={
|
|
434
|
+
typeof local.style === "string"
|
|
435
|
+
? `${widths};${local.style}`
|
|
436
|
+
: {
|
|
437
|
+
"--sidebar-width": SIDEBAR_WIDTH,
|
|
438
|
+
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
|
439
|
+
...local.style,
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
class={cn(
|
|
443
|
+
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
|
|
444
|
+
local.class,
|
|
445
|
+
)}
|
|
446
|
+
{...rest}
|
|
447
|
+
>
|
|
448
|
+
{local.children}
|
|
449
|
+
</div>
|
|
450
|
+
</TooltipProvider>
|
|
451
|
+
</sidebarContext.Provider>
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Props of {@link Sidebar}: the attributes of a `div` plus where it sits and how it collapses.
|
|
457
|
+
* @since 1.0.0
|
|
458
|
+
* @see {@link Sidebar}
|
|
459
|
+
*/
|
|
460
|
+
export type SidebarProps = ComponentProps<"div"> & {
|
|
461
|
+
/**
|
|
462
|
+
* The edge the sidebar sits on. `"start"` and `"end"` are inline edges: start is the left in
|
|
463
|
+
* left-to-right text and the right in right-to-left text. `"left"` and `"right"` (shadcn/ui's names)
|
|
464
|
+
* are physical edges: the sidebar stays on that side of the screen, so in right-to-left text `"left"`
|
|
465
|
+
* means the end edge. Prefer `"start"` and `"end"` for a layout that should mirror.
|
|
466
|
+
* @default "start"
|
|
467
|
+
*/
|
|
468
|
+
readonly side?: "start" | "end" | "left" | "right" | undefined;
|
|
469
|
+
/**
|
|
470
|
+
* `"sidebar"` is the flat panel, `"floating"` a rounded panel with a border, and `"inset"` a panel
|
|
471
|
+
* next to an inset page (see {@link SidebarInset}).
|
|
472
|
+
* @default "sidebar"
|
|
473
|
+
*/
|
|
474
|
+
readonly variant?: "sidebar" | "floating" | "inset" | undefined;
|
|
475
|
+
/**
|
|
476
|
+
* `"offcanvas"` slides the sidebar out of view, `"icon"` shrinks it to an icon rail, and `"none"`
|
|
477
|
+
* keeps it open.
|
|
478
|
+
* @default "offcanvas"
|
|
479
|
+
*/
|
|
480
|
+
readonly collapsible?: "offcanvas" | "icon" | "none" | undefined;
|
|
481
|
+
/**
|
|
482
|
+
* The element that holds the sidebar. Pass `"div"` when the sidebar sits inside another landmark,
|
|
483
|
+
* since a landmark may not nest in one. `aria-label` (default "Sidebar") or `aria-labelledby` names
|
|
484
|
+
* the landmark.
|
|
485
|
+
* @default "aside"
|
|
486
|
+
*/
|
|
487
|
+
readonly as?: "aside" | "nav" | "section" | "div" | undefined;
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* The accessible name of the sidebar's element: the caller's `aria-labelledby` takes precedence,
|
|
492
|
+
* else the caller's `aria-label`, else the default name ("Sidebar") for a landmark. A plain `div` gets no default name,
|
|
493
|
+
* since ARIA does not allow a name on a generic element.
|
|
494
|
+
*/
|
|
495
|
+
function landmarkLabel(
|
|
496
|
+
element: string,
|
|
497
|
+
label: string | null | undefined,
|
|
498
|
+
labelledBy: string | null | undefined,
|
|
499
|
+
fallback: string,
|
|
500
|
+
): string | undefined {
|
|
501
|
+
if (labelledBy !== undefined && labelledBy !== null) return undefined;
|
|
502
|
+
if (typeof label === "string") return label;
|
|
503
|
+
return element === "div" ? undefined : fallback;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* The sidebar panel. On a wide viewport it is a fixed panel that can collapse; on a narrow one it
|
|
508
|
+
* opens as a sheet from the same side (toggled by {@link SidebarTrigger}). Put the header, the
|
|
509
|
+
* content and the footer inside it. Its root carries `data-state`, `data-collapsible`,
|
|
510
|
+
* `data-variant` and `data-side`, which style the parts inside.
|
|
511
|
+
*
|
|
512
|
+
* Keyboard and ARIA: the root is an `aside` landmark named "Sidebar" by default. Pass `aria-label`
|
|
513
|
+
* or `aria-labelledby` to name it, and `as` to change the element. Keyboard users toggle the
|
|
514
|
+
* sidebar with Ctrl+B or Cmd+B (see {@link SidebarProvider}) or with the {@link SidebarTrigger}. On
|
|
515
|
+
* a mobile layout the sheet's title is a visually hidden "Sidebar" and Escape closes the sheet. With
|
|
516
|
+
* `collapsible="none"` the panel cannot collapse; an `offcanvas` panel that is collapsed slides out
|
|
517
|
+
* of view.
|
|
518
|
+
*
|
|
519
|
+
* shadcn/ui differences: the root is an `aside` landmark, where shadcn/ui renders a plain `div`.
|
|
520
|
+
* `as` picks that element, and `class` replaces `className`.
|
|
521
|
+
*
|
|
522
|
+
* @param props - The props, see {@link SidebarProps}.
|
|
523
|
+
* @returns The sidebar: a panel on wide viewports, or a sheet on narrow ones.
|
|
524
|
+
* @example
|
|
525
|
+
* ```tsx
|
|
526
|
+
* import {
|
|
527
|
+
* Sidebar,
|
|
528
|
+
* SidebarContent,
|
|
529
|
+
* SidebarFooter,
|
|
530
|
+
* SidebarHeader,
|
|
531
|
+
* SidebarProvider,
|
|
532
|
+
* SidebarRail,
|
|
533
|
+
* } from "@/components/ui/sidebar";
|
|
534
|
+
*
|
|
535
|
+
* export function Workspace() {
|
|
536
|
+
* return (
|
|
537
|
+
* <SidebarProvider>
|
|
538
|
+
* <Sidebar collapsible="icon" aria-label="Workspace">
|
|
539
|
+
* <SidebarHeader>Acme</SidebarHeader>
|
|
540
|
+
* <SidebarContent>Projects</SidebarContent>
|
|
541
|
+
* <SidebarFooter>Account</SidebarFooter>
|
|
542
|
+
* <SidebarRail />
|
|
543
|
+
* </Sidebar>
|
|
544
|
+
* </SidebarProvider>
|
|
545
|
+
* );
|
|
546
|
+
* }
|
|
547
|
+
* ```
|
|
548
|
+
* @since 1.0.0
|
|
549
|
+
* @see {@link SidebarProvider}
|
|
550
|
+
*/
|
|
551
|
+
function Sidebar(props: SidebarProps): JSX.Element {
|
|
552
|
+
const [local, rest] = splitProps(props, [
|
|
553
|
+
"side",
|
|
554
|
+
"variant",
|
|
555
|
+
"collapsible",
|
|
556
|
+
"class",
|
|
557
|
+
"children",
|
|
558
|
+
"as",
|
|
559
|
+
"aria-label",
|
|
560
|
+
"aria-labelledby",
|
|
561
|
+
]);
|
|
562
|
+
const sidebar = useSidebar();
|
|
563
|
+
const t = useUiStrings();
|
|
564
|
+
// A physical edge becomes the inline edge that is there in the current reading direction.
|
|
565
|
+
const side = (): "start" | "end" => {
|
|
566
|
+
const wanted = local.side ?? "start";
|
|
567
|
+
if (wanted === "start" || wanted === "end") return wanted;
|
|
568
|
+
const leftIsStart = sidebar.dir() === "ltr";
|
|
569
|
+
return (wanted === "left") === leftIsStart ? "start" : "end";
|
|
570
|
+
};
|
|
571
|
+
const variant = (): "sidebar" | "floating" | "inset" => local.variant ?? "sidebar";
|
|
572
|
+
const collapsible = (): "offcanvas" | "icon" | "none" => local.collapsible ?? "offcanvas";
|
|
573
|
+
const spaced = (): boolean => variant() === "floating" || variant() === "inset";
|
|
574
|
+
// Off-canvas and collapsed, the panel is out of view: its content must not stay reachable.
|
|
575
|
+
const hidden = (): boolean =>
|
|
576
|
+
!sidebar.isMobile() && sidebar.state() === "collapsed" && collapsible() === "offcanvas";
|
|
577
|
+
return (
|
|
578
|
+
<sidebarSideContext.Provider value={side}>
|
|
579
|
+
<sidebarHiddenContext.Provider value={hidden}>
|
|
580
|
+
<Show
|
|
581
|
+
when={collapsible() === "none"}
|
|
582
|
+
fallback={
|
|
583
|
+
<Show
|
|
584
|
+
when={sidebar.isMobile()}
|
|
585
|
+
fallback={
|
|
586
|
+
<Polymorphic
|
|
587
|
+
as={local.as ?? "aside"}
|
|
588
|
+
aria-label={landmarkLabel(
|
|
589
|
+
local.as ?? "aside",
|
|
590
|
+
local["aria-label"],
|
|
591
|
+
local["aria-labelledby"],
|
|
592
|
+
t("sidebar.title", "Sidebar"),
|
|
593
|
+
)}
|
|
594
|
+
aria-labelledby={local["aria-labelledby"]}
|
|
595
|
+
class="group peer hidden text-sidebar-foreground md:block"
|
|
596
|
+
data-state={sidebar.state()}
|
|
597
|
+
data-collapsible={sidebar.state() === "collapsed" ? collapsible() : ""}
|
|
598
|
+
data-variant={variant()}
|
|
599
|
+
data-side={side()}
|
|
600
|
+
data-slot="sidebar"
|
|
601
|
+
>
|
|
602
|
+
<div
|
|
603
|
+
data-slot="sidebar-gap"
|
|
604
|
+
class={cn(
|
|
605
|
+
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear group-data-[collapsible=offcanvas]:w-0 motion-reduce:transition-none",
|
|
606
|
+
spaced()
|
|
607
|
+
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
|
|
608
|
+
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
|
|
609
|
+
)}
|
|
610
|
+
/>
|
|
611
|
+
<div
|
|
612
|
+
data-slot="sidebar-container"
|
|
613
|
+
class={cn(
|
|
614
|
+
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear motion-reduce:transition-none md:flex",
|
|
615
|
+
side() === "start"
|
|
616
|
+
? "start-0 group-data-[collapsible=offcanvas]:start-[calc(var(--sidebar-width)*-1)]"
|
|
617
|
+
: "end-0 group-data-[collapsible=offcanvas]:end-[calc(var(--sidebar-width)*-1)]",
|
|
618
|
+
spaced()
|
|
619
|
+
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
|
620
|
+
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=end]:border-s group-data-[side=start]:border-e",
|
|
621
|
+
local.class,
|
|
622
|
+
)}
|
|
623
|
+
{...rest}
|
|
624
|
+
>
|
|
625
|
+
<div
|
|
626
|
+
data-sidebar="sidebar"
|
|
627
|
+
data-slot="sidebar-inner"
|
|
628
|
+
class="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow-sm"
|
|
629
|
+
>
|
|
630
|
+
{local.children}
|
|
631
|
+
</div>
|
|
632
|
+
</div>
|
|
633
|
+
</Polymorphic>
|
|
634
|
+
}
|
|
635
|
+
>
|
|
636
|
+
<Sheet open={sidebar.openMobile()} onOpenChange={sidebar.setOpenMobile}>
|
|
637
|
+
<SheetContent
|
|
638
|
+
data-sidebar="sidebar"
|
|
639
|
+
data-slot="sidebar"
|
|
640
|
+
data-mobile="true"
|
|
641
|
+
side={side()}
|
|
642
|
+
showCloseButton={false}
|
|
643
|
+
class="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground"
|
|
644
|
+
style={{ "--sidebar-width": SIDEBAR_WIDTH_MOBILE }}
|
|
645
|
+
>
|
|
646
|
+
<SheetHeader class="sr-only">
|
|
647
|
+
<SheetTitle>{t("sidebar.title", "Sidebar")}</SheetTitle>
|
|
648
|
+
<SheetDescription>
|
|
649
|
+
{t("sidebar.mobileDescription", "Displays the mobile sidebar.")}
|
|
650
|
+
</SheetDescription>
|
|
651
|
+
</SheetHeader>
|
|
652
|
+
<Polymorphic
|
|
653
|
+
as={local.as ?? "aside"}
|
|
654
|
+
aria-label={landmarkLabel(
|
|
655
|
+
local.as ?? "aside",
|
|
656
|
+
local["aria-label"],
|
|
657
|
+
local["aria-labelledby"],
|
|
658
|
+
t("sidebar.title", "Sidebar"),
|
|
659
|
+
)}
|
|
660
|
+
aria-labelledby={local["aria-labelledby"]}
|
|
661
|
+
class="flex h-full w-full flex-col"
|
|
662
|
+
>
|
|
663
|
+
{local.children}
|
|
664
|
+
</Polymorphic>
|
|
665
|
+
</SheetContent>
|
|
666
|
+
</Sheet>
|
|
667
|
+
</Show>
|
|
668
|
+
}
|
|
669
|
+
>
|
|
670
|
+
<Polymorphic
|
|
671
|
+
as={local.as ?? "aside"}
|
|
672
|
+
aria-label={landmarkLabel(
|
|
673
|
+
local.as ?? "aside",
|
|
674
|
+
local["aria-label"],
|
|
675
|
+
local["aria-labelledby"],
|
|
676
|
+
t("sidebar.title", "Sidebar"),
|
|
677
|
+
)}
|
|
678
|
+
aria-labelledby={local["aria-labelledby"]}
|
|
679
|
+
data-slot="sidebar"
|
|
680
|
+
class={cn(
|
|
681
|
+
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
|
|
682
|
+
local.class,
|
|
683
|
+
)}
|
|
684
|
+
{...rest}
|
|
685
|
+
>
|
|
686
|
+
{local.children}
|
|
687
|
+
</Polymorphic>
|
|
688
|
+
</Show>
|
|
689
|
+
</sidebarHiddenContext.Provider>
|
|
690
|
+
</sidebarSideContext.Provider>
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* The button that toggles the sidebar, or its mobile sheet on a narrow viewport. It renders a
|
|
696
|
+
* ghost icon {@link Button} with a visually hidden "Toggle Sidebar" name.
|
|
697
|
+
*
|
|
698
|
+
* Keyboard and ARIA: Enter and Space toggle it (it is a button). Its name is "Toggle Sidebar" (the UI
|
|
699
|
+
* string `sidebar.toggle`), and `aria-expanded` says whether the sidebar (the mobile sheet on a narrow
|
|
700
|
+
* viewport) is open.
|
|
701
|
+
*
|
|
702
|
+
* shadcn/ui differences: `class` replaces `className`.
|
|
703
|
+
*
|
|
704
|
+
* @param props - The props: the `Button` props, `onClick` (called before the toggle), and `class`.
|
|
705
|
+
* @returns The button.
|
|
706
|
+
* @example
|
|
707
|
+
* ```tsx
|
|
708
|
+
* import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
|
709
|
+
*
|
|
710
|
+
* export function Header() {
|
|
711
|
+
* return (
|
|
712
|
+
* <SidebarProvider>
|
|
713
|
+
* <header>
|
|
714
|
+
* <SidebarTrigger />
|
|
715
|
+
* </header>
|
|
716
|
+
* </SidebarProvider>
|
|
717
|
+
* );
|
|
718
|
+
* }
|
|
719
|
+
* ```
|
|
720
|
+
* @since 1.0.0
|
|
721
|
+
* @see {@link SidebarProvider}
|
|
722
|
+
*/
|
|
723
|
+
function SidebarTrigger(props: ButtonProps<"button">): JSX.Element {
|
|
724
|
+
const [local, rest] = splitProps(props, ["class", "onClick"]);
|
|
725
|
+
const sidebar = useSidebar();
|
|
726
|
+
const t = useUiStrings();
|
|
727
|
+
return (
|
|
728
|
+
<Button
|
|
729
|
+
data-sidebar="trigger"
|
|
730
|
+
data-slot="sidebar-trigger"
|
|
731
|
+
variant="ghost"
|
|
732
|
+
size="icon"
|
|
733
|
+
aria-expanded={sidebar.isMobile() ? sidebar.openMobile() : sidebar.open()}
|
|
734
|
+
class={cn("size-7", local.class)}
|
|
735
|
+
onClick={(event: MouseEvent) => {
|
|
736
|
+
callHandler(local.onClick, event);
|
|
737
|
+
sidebar.toggleSidebar();
|
|
738
|
+
}}
|
|
739
|
+
{...rest}
|
|
740
|
+
>
|
|
741
|
+
<PanelLeftIcon class="rtl:rotate-180" />
|
|
742
|
+
<span class="sr-only">{t("sidebar.toggle", "Toggle Sidebar")}</span>
|
|
743
|
+
</Button>
|
|
744
|
+
);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* A thin strip along the sidebar's inner edge that toggles the sidebar when it is clicked. Place it
|
|
749
|
+
* inside a {@link Sidebar}.
|
|
750
|
+
*
|
|
751
|
+
* Keyboard and ARIA: the rail is pointer only. It is not in the tab order (`tabindex="-1"`); keyboard
|
|
752
|
+
* users toggle the sidebar with {@link SidebarTrigger} or Ctrl+B. It is named "Toggle Sidebar".
|
|
753
|
+
*
|
|
754
|
+
* shadcn/ui differences: `class` replaces `className`.
|
|
755
|
+
*
|
|
756
|
+
* @param props - The props: the attributes of the `button`, and `class`.
|
|
757
|
+
* @returns The rail button.
|
|
758
|
+
* @example
|
|
759
|
+
* ```tsx
|
|
760
|
+
* import { Sidebar, SidebarContent, SidebarProvider, SidebarRail } from "@/components/ui/sidebar";
|
|
761
|
+
*
|
|
762
|
+
* export function Edge() {
|
|
763
|
+
* return (
|
|
764
|
+
* <SidebarProvider>
|
|
765
|
+
* <Sidebar>
|
|
766
|
+
* <SidebarContent>Navigation</SidebarContent>
|
|
767
|
+
* <SidebarRail />
|
|
768
|
+
* </Sidebar>
|
|
769
|
+
* </SidebarProvider>
|
|
770
|
+
* );
|
|
771
|
+
* }
|
|
772
|
+
* ```
|
|
773
|
+
* @since 1.0.0
|
|
774
|
+
* @see {@link Sidebar}
|
|
775
|
+
*/
|
|
776
|
+
function SidebarRail(props: ComponentProps<"button">): JSX.Element {
|
|
777
|
+
const [local, rest] = splitProps(props, ["class"]);
|
|
778
|
+
const sidebar = useSidebar();
|
|
779
|
+
const t = useUiStrings();
|
|
780
|
+
return (
|
|
781
|
+
<button
|
|
782
|
+
type="button"
|
|
783
|
+
data-sidebar="rail"
|
|
784
|
+
data-slot="sidebar-rail"
|
|
785
|
+
aria-label={t("sidebar.toggle", "Toggle Sidebar")}
|
|
786
|
+
tabindex={-1}
|
|
787
|
+
onClick={sidebar.toggleSidebar}
|
|
788
|
+
title={t("sidebar.toggle", "Toggle Sidebar")}
|
|
789
|
+
class={cn(
|
|
790
|
+
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=end]:start-0 group-data-[side=start]:-end-4 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border motion-reduce:transition-none sm:flex rtl:translate-x-1/2",
|
|
791
|
+
"in-data-[side=end]:cursor-e-resize in-data-[side=start]:cursor-w-resize rtl:in-data-[side=end]:cursor-w-resize rtl:in-data-[side=start]:cursor-e-resize",
|
|
792
|
+
"[[data-side=end][data-state=collapsed]_&]:cursor-w-resize [[data-side=start][data-state=collapsed]_&]:cursor-e-resize rtl:[[data-side=end][data-state=collapsed]_&]:cursor-e-resize rtl:[[data-side=start][data-state=collapsed]_&]:cursor-w-resize",
|
|
793
|
+
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:start-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
|
|
794
|
+
"[[data-side=end][data-collapsible=offcanvas]_&]:-start-2 [[data-side=start][data-collapsible=offcanvas]_&]:-end-2",
|
|
795
|
+
local.class,
|
|
796
|
+
)}
|
|
797
|
+
{...rest}
|
|
798
|
+
/>
|
|
799
|
+
);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* Props of {@link SidebarInset}: the attributes of a `main` plus the element it renders as.
|
|
804
|
+
* @since 1.0.0
|
|
805
|
+
* @see {@link SidebarInset}
|
|
806
|
+
*/
|
|
807
|
+
export type SidebarInsetProps = ComponentProps<"main"> & {
|
|
808
|
+
/**
|
|
809
|
+
* The element of the page content. Pass `"div"` when the inset sits inside another `main` landmark,
|
|
810
|
+
* since a page may have one main landmark.
|
|
811
|
+
* @default "main"
|
|
812
|
+
*/
|
|
813
|
+
readonly as?: "main" | "div" | undefined;
|
|
814
|
+
};
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* The page content next to the sidebar. It is the page's `main` landmark by default. With
|
|
818
|
+
* `variant="inset"` on the sidebar it becomes a rounded card.
|
|
819
|
+
*
|
|
820
|
+
* Keyboard and ARIA: no keys of its own. It is a `main` landmark unless `as` changes it.
|
|
821
|
+
*
|
|
822
|
+
* shadcn/ui differences: `class` replaces `className`.
|
|
823
|
+
*
|
|
824
|
+
* @param props - The props: `as` and the attributes of the element.
|
|
825
|
+
* @returns The inset element with the page inside.
|
|
826
|
+
* @example
|
|
827
|
+
* ```tsx
|
|
828
|
+
* import { Sidebar, SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
|
829
|
+
*
|
|
830
|
+
* export function Page() {
|
|
831
|
+
* return (
|
|
832
|
+
* <SidebarProvider>
|
|
833
|
+
* <Sidebar />
|
|
834
|
+
* <SidebarInset>
|
|
835
|
+
* <header>Dashboard</header>
|
|
836
|
+
* </SidebarInset>
|
|
837
|
+
* </SidebarProvider>
|
|
838
|
+
* );
|
|
839
|
+
* }
|
|
840
|
+
* ```
|
|
841
|
+
* @since 1.0.0
|
|
842
|
+
* @see {@link SidebarProvider}
|
|
843
|
+
*/
|
|
844
|
+
function SidebarInset(props: SidebarInsetProps): JSX.Element {
|
|
845
|
+
const [local, rest] = splitProps(props, ["as", "class", "children"]);
|
|
846
|
+
return (
|
|
847
|
+
<Polymorphic
|
|
848
|
+
as={local.as ?? "main"}
|
|
849
|
+
data-slot="sidebar-inset"
|
|
850
|
+
class={cn(
|
|
851
|
+
"relative flex w-full flex-1 flex-col bg-background",
|
|
852
|
+
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ms-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ms-2",
|
|
853
|
+
local.class,
|
|
854
|
+
)}
|
|
855
|
+
{...rest}
|
|
856
|
+
>
|
|
857
|
+
{local.children}
|
|
858
|
+
</Polymorphic>
|
|
859
|
+
);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* A text field styled for the sidebar, typically a search box. It is the kit's {@link Input} with
|
|
864
|
+
* the sidebar's height and background.
|
|
865
|
+
*
|
|
866
|
+
* Keyboard and ARIA: a native `input`; it takes the usual text keys. Name it with a label
|
|
867
|
+
* (`id` on the input, `htmlFor` on the label) or with `aria-label`, since a placeholder is not a name.
|
|
868
|
+
*
|
|
869
|
+
* shadcn/ui differences: `class` replaces `className`.
|
|
870
|
+
*
|
|
871
|
+
* @param props - The props: the attributes of the `input`, and `class`.
|
|
872
|
+
* @returns The input element.
|
|
873
|
+
* @example
|
|
874
|
+
* ```tsx
|
|
875
|
+
* import { SidebarInput } from "@/components/ui/sidebar";
|
|
876
|
+
*
|
|
877
|
+
* export function SidebarSearch() {
|
|
878
|
+
* return <SidebarInput type="search" aria-label="Search the docs" placeholder="Search the docs..." />;
|
|
879
|
+
* }
|
|
880
|
+
* ```
|
|
881
|
+
* @since 1.0.0
|
|
882
|
+
*/
|
|
883
|
+
function SidebarInput(props: ComponentProps<"input">): JSX.Element {
|
|
884
|
+
const [local, rest] = splitProps(props, ["class"]);
|
|
885
|
+
return (
|
|
886
|
+
<Input
|
|
887
|
+
data-slot="sidebar-input"
|
|
888
|
+
data-sidebar="input"
|
|
889
|
+
class={cn("h-8 w-full bg-background shadow-none", local.class)}
|
|
890
|
+
{...rest}
|
|
891
|
+
/>
|
|
892
|
+
);
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* The top of the sidebar, for a logo or a workspace switcher. It stays at the top while the content
|
|
897
|
+
* scrolls.
|
|
898
|
+
*
|
|
899
|
+
* Keyboard and ARIA: no keys and no role of its own; a `div`.
|
|
900
|
+
*
|
|
901
|
+
* shadcn/ui differences: `class` replaces `className`.
|
|
902
|
+
*
|
|
903
|
+
* @param props - The props: the attributes of the `div`, and `class`.
|
|
904
|
+
* @returns The header `div`.
|
|
905
|
+
* @example
|
|
906
|
+
* ```tsx
|
|
907
|
+
* import { SidebarHeader } from "@/components/ui/sidebar";
|
|
908
|
+
*
|
|
909
|
+
* export function Brand() {
|
|
910
|
+
* return <SidebarHeader>Acme workspace</SidebarHeader>;
|
|
911
|
+
* }
|
|
912
|
+
* ```
|
|
913
|
+
* @since 1.0.0
|
|
914
|
+
*/
|
|
915
|
+
function SidebarHeader(props: ComponentProps<"div">): JSX.Element {
|
|
916
|
+
const [local, rest] = splitProps(props, ["class", "children"]);
|
|
917
|
+
const hidden = sidebarHiddenContext.useOptional();
|
|
918
|
+
return (
|
|
919
|
+
<div
|
|
920
|
+
data-slot="sidebar-header"
|
|
921
|
+
data-sidebar="header"
|
|
922
|
+
inert={hidden?.() ? true : undefined}
|
|
923
|
+
class={cn("flex flex-col gap-2 p-2", local.class)}
|
|
924
|
+
{...rest}
|
|
925
|
+
>
|
|
926
|
+
{local.children}
|
|
927
|
+
</div>
|
|
928
|
+
);
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/**
|
|
932
|
+
* The bottom of the sidebar, for an account menu or other secondary actions.
|
|
933
|
+
*
|
|
934
|
+
* Keyboard and ARIA: no keys and no role of its own; a `div`.
|
|
935
|
+
*
|
|
936
|
+
* shadcn/ui differences: `class` replaces `className`.
|
|
937
|
+
*
|
|
938
|
+
* @param props - The props: the attributes of the `div`, and `class`.
|
|
939
|
+
* @returns The footer `div`.
|
|
940
|
+
* @example
|
|
941
|
+
* ```tsx
|
|
942
|
+
* import { SidebarFooter } from "@/components/ui/sidebar";
|
|
943
|
+
*
|
|
944
|
+
* export function Account() {
|
|
945
|
+
* return <SidebarFooter>Signed in as Ada</SidebarFooter>;
|
|
946
|
+
* }
|
|
947
|
+
* ```
|
|
948
|
+
* @since 1.0.0
|
|
949
|
+
*/
|
|
950
|
+
function SidebarFooter(props: ComponentProps<"div">): JSX.Element {
|
|
951
|
+
const [local, rest] = splitProps(props, ["class", "children"]);
|
|
952
|
+
const hidden = sidebarHiddenContext.useOptional();
|
|
953
|
+
return (
|
|
954
|
+
<div
|
|
955
|
+
data-slot="sidebar-footer"
|
|
956
|
+
data-sidebar="footer"
|
|
957
|
+
inert={hidden?.() ? true : undefined}
|
|
958
|
+
class={cn("flex flex-col gap-2 p-2", local.class)}
|
|
959
|
+
{...rest}
|
|
960
|
+
>
|
|
961
|
+
{local.children}
|
|
962
|
+
</div>
|
|
963
|
+
);
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/**
|
|
967
|
+
* A line between sidebar sections. It is the {@link Separator} of the kit, styled for the sidebar.
|
|
968
|
+
*
|
|
969
|
+
* Keyboard and ARIA: no keys. It is a separator with `role="none"` unless `decorative={false}` is
|
|
970
|
+
* passed.
|
|
971
|
+
*
|
|
972
|
+
* shadcn/ui differences: `class` replaces `className`.
|
|
973
|
+
*
|
|
974
|
+
* @param props - The props: `orientation`, `decorative`, the attributes of the element, and `class`.
|
|
975
|
+
* @returns The separator element.
|
|
976
|
+
* @example
|
|
977
|
+
* ```tsx
|
|
978
|
+
* import { SidebarContent, SidebarSeparator } from "@/components/ui/sidebar";
|
|
979
|
+
*
|
|
980
|
+
* export function Sections() {
|
|
981
|
+
* return (
|
|
982
|
+
* <SidebarContent>
|
|
983
|
+
* <span>Projects</span>
|
|
984
|
+
* <SidebarSeparator />
|
|
985
|
+
* <span>Teams</span>
|
|
986
|
+
* </SidebarContent>
|
|
987
|
+
* );
|
|
988
|
+
* }
|
|
989
|
+
* ```
|
|
990
|
+
* @since 1.0.0
|
|
991
|
+
*/
|
|
992
|
+
function SidebarSeparator(props: SeparatorProps): JSX.Element {
|
|
993
|
+
const [local, rest] = splitProps(props, ["class"]);
|
|
994
|
+
return (
|
|
995
|
+
<Separator
|
|
996
|
+
data-slot="sidebar-separator"
|
|
997
|
+
data-sidebar="separator"
|
|
998
|
+
class={cn("mx-2 w-auto bg-sidebar-border", local.class)}
|
|
999
|
+
{...rest}
|
|
1000
|
+
/>
|
|
1001
|
+
);
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
/**
|
|
1005
|
+
* The scrollable middle of the sidebar, holding the groups. It scrolls on its own, so the header and
|
|
1006
|
+
* footer stay in place.
|
|
1007
|
+
*
|
|
1008
|
+
* Keyboard and ARIA: no keys and no role of its own. Focusable items inside it are reached with Tab.
|
|
1009
|
+
*
|
|
1010
|
+
* shadcn/ui differences: `class` replaces `className`.
|
|
1011
|
+
*
|
|
1012
|
+
* @param props - The props: the attributes of the `div`, and `class`.
|
|
1013
|
+
* @returns The content `div`.
|
|
1014
|
+
* @example
|
|
1015
|
+
* ```tsx
|
|
1016
|
+
* import { SidebarContent, SidebarGroup, SidebarGroupLabel } from "@/components/ui/sidebar";
|
|
1017
|
+
*
|
|
1018
|
+
* export function Navigation() {
|
|
1019
|
+
* return (
|
|
1020
|
+
* <SidebarContent>
|
|
1021
|
+
* <SidebarGroup>
|
|
1022
|
+
* <SidebarGroupLabel>Application</SidebarGroupLabel>
|
|
1023
|
+
* </SidebarGroup>
|
|
1024
|
+
* </SidebarContent>
|
|
1025
|
+
* );
|
|
1026
|
+
* }
|
|
1027
|
+
* ```
|
|
1028
|
+
* @since 1.0.0
|
|
1029
|
+
*/
|
|
1030
|
+
function SidebarContent(props: ComponentProps<"div">): JSX.Element {
|
|
1031
|
+
const [local, rest] = splitProps(props, ["class", "children"]);
|
|
1032
|
+
const hidden = sidebarHiddenContext.useOptional();
|
|
1033
|
+
return (
|
|
1034
|
+
<div
|
|
1035
|
+
data-slot="sidebar-content"
|
|
1036
|
+
data-sidebar="content"
|
|
1037
|
+
inert={hidden?.() ? true : undefined}
|
|
1038
|
+
class={cn(
|
|
1039
|
+
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
|
1040
|
+
local.class,
|
|
1041
|
+
)}
|
|
1042
|
+
{...rest}
|
|
1043
|
+
>
|
|
1044
|
+
{local.children}
|
|
1045
|
+
</div>
|
|
1046
|
+
);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/**
|
|
1050
|
+
* A section of the sidebar: a label, an optional action, and the content (usually a
|
|
1051
|
+
* {@link SidebarMenu}).
|
|
1052
|
+
*
|
|
1053
|
+
* Keyboard and ARIA: no keys and no role of its own; a `div`.
|
|
1054
|
+
*
|
|
1055
|
+
* shadcn/ui differences: `class` replaces `className`.
|
|
1056
|
+
*
|
|
1057
|
+
* @param props - The props: the attributes of the `div`, and `class`.
|
|
1058
|
+
* @returns The group `div`.
|
|
1059
|
+
* @example
|
|
1060
|
+
* ```tsx
|
|
1061
|
+
* import {
|
|
1062
|
+
* SidebarGroup,
|
|
1063
|
+
* SidebarGroupContent,
|
|
1064
|
+
* SidebarGroupLabel,
|
|
1065
|
+
* SidebarMenu,
|
|
1066
|
+
* } from "@/components/ui/sidebar";
|
|
1067
|
+
*
|
|
1068
|
+
* export function ProjectsGroup() {
|
|
1069
|
+
* return (
|
|
1070
|
+
* <SidebarGroup>
|
|
1071
|
+
* <SidebarGroupLabel>Projects</SidebarGroupLabel>
|
|
1072
|
+
* <SidebarGroupContent>
|
|
1073
|
+
* <SidebarMenu />
|
|
1074
|
+
* </SidebarGroupContent>
|
|
1075
|
+
* </SidebarGroup>
|
|
1076
|
+
* );
|
|
1077
|
+
* }
|
|
1078
|
+
* ```
|
|
1079
|
+
* @since 1.0.0
|
|
1080
|
+
* @see {@link SidebarGroupLabel}
|
|
1081
|
+
*/
|
|
1082
|
+
function SidebarGroup(props: ComponentProps<"div">): JSX.Element {
|
|
1083
|
+
const [local, rest] = splitProps(props, ["class", "children"]);
|
|
1084
|
+
return (
|
|
1085
|
+
<div
|
|
1086
|
+
data-slot="sidebar-group"
|
|
1087
|
+
data-sidebar="group"
|
|
1088
|
+
class={cn("relative flex w-full min-w-0 flex-col p-2", local.class)}
|
|
1089
|
+
{...rest}
|
|
1090
|
+
>
|
|
1091
|
+
{local.children}
|
|
1092
|
+
</div>
|
|
1093
|
+
);
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
/**
|
|
1097
|
+
* Props of {@link SidebarGroupLabel}.
|
|
1098
|
+
* @since 1.0.0
|
|
1099
|
+
* @see {@link SidebarGroupLabel}
|
|
1100
|
+
*/
|
|
1101
|
+
export type SidebarGroupLabelProps<T extends PolymorphicTarget = "div"> = PolymorphicProps<
|
|
1102
|
+
"div",
|
|
1103
|
+
object,
|
|
1104
|
+
T
|
|
1105
|
+
>;
|
|
1106
|
+
|
|
1107
|
+
/**
|
|
1108
|
+
* The heading of a group. It fades out while the sidebar is collapsed to icons.
|
|
1109
|
+
*
|
|
1110
|
+
* Keyboard and ARIA: no keys, and it is a `div` with no role. It can be focused (`focus-visible`
|
|
1111
|
+
* styles apply) when you pass `tabindex`, but it is not a heading unless you render one with `as`.
|
|
1112
|
+
*
|
|
1113
|
+
* shadcn/ui differences: `class` replaces `className`, and `as` replaces `asChild`.
|
|
1114
|
+
*
|
|
1115
|
+
* @param props - The props: `as` (default `div`), and the attributes of the element.
|
|
1116
|
+
* @returns The label element.
|
|
1117
|
+
* @example
|
|
1118
|
+
* ```tsx
|
|
1119
|
+
* import { SidebarGroup, SidebarGroupLabel } from "@/components/ui/sidebar";
|
|
1120
|
+
*
|
|
1121
|
+
* export function Application() {
|
|
1122
|
+
* return (
|
|
1123
|
+
* <SidebarGroup>
|
|
1124
|
+
* <SidebarGroupLabel as="h2">Application</SidebarGroupLabel>
|
|
1125
|
+
* </SidebarGroup>
|
|
1126
|
+
* );
|
|
1127
|
+
* }
|
|
1128
|
+
* ```
|
|
1129
|
+
* @since 1.0.0
|
|
1130
|
+
*/
|
|
1131
|
+
function SidebarGroupLabel<T extends PolymorphicTarget = "div">(
|
|
1132
|
+
props: SidebarGroupLabelProps<T>,
|
|
1133
|
+
): JSX.Element {
|
|
1134
|
+
const [local, rest] = splitProps(props as unknown as Loose, ["as", "class"]);
|
|
1135
|
+
return (
|
|
1136
|
+
<Polymorphic
|
|
1137
|
+
as={local.as ?? "div"}
|
|
1138
|
+
data-slot="sidebar-group-label"
|
|
1139
|
+
data-sidebar="group-label"
|
|
1140
|
+
class={cn(
|
|
1141
|
+
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-none transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 motion-reduce:transition-none [&>svg]:size-4 [&>svg]:shrink-0 focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-transparent",
|
|
1142
|
+
local.class,
|
|
1143
|
+
)}
|
|
1144
|
+
{...rest}
|
|
1145
|
+
/>
|
|
1146
|
+
);
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
/**
|
|
1150
|
+
* Props of {@link SidebarGroupAction}.
|
|
1151
|
+
* @since 1.0.0
|
|
1152
|
+
* @see {@link SidebarGroupAction}
|
|
1153
|
+
*/
|
|
1154
|
+
export type SidebarGroupActionProps<T extends PolymorphicTarget = "button"> = PolymorphicProps<
|
|
1155
|
+
"button",
|
|
1156
|
+
object,
|
|
1157
|
+
T
|
|
1158
|
+
>;
|
|
1159
|
+
|
|
1160
|
+
/**
|
|
1161
|
+
* A small button in the corner of a group, for example to add an item. It is hidden while the
|
|
1162
|
+
* sidebar shows icons only.
|
|
1163
|
+
*
|
|
1164
|
+
* Keyboard and ARIA: a `button` (Enter and Space press it). Name it with its text or `aria-label`.
|
|
1165
|
+
* A `title` alone is not a reliable name, so pass `aria-label` for an icon-only action.
|
|
1166
|
+
*
|
|
1167
|
+
* shadcn/ui differences: `class` replaces `className`, and `as` replaces `asChild`.
|
|
1168
|
+
*
|
|
1169
|
+
* @param props - The props: `as` (default `button`), and the attributes of the element.
|
|
1170
|
+
* @returns The action button.
|
|
1171
|
+
* @example
|
|
1172
|
+
* ```tsx
|
|
1173
|
+
* import { ChevronRightIcon } from "@/components/ui/icons";
|
|
1174
|
+
* import { SidebarGroup, SidebarGroupAction, SidebarGroupLabel } from "@/components/ui/sidebar";
|
|
1175
|
+
*
|
|
1176
|
+
* export function AddProject() {
|
|
1177
|
+
* return (
|
|
1178
|
+
* <SidebarGroup>
|
|
1179
|
+
* <SidebarGroupLabel>Projects</SidebarGroupLabel>
|
|
1180
|
+
* <SidebarGroupAction aria-label="Open projects">
|
|
1181
|
+
* <ChevronRightIcon />
|
|
1182
|
+
* </SidebarGroupAction>
|
|
1183
|
+
* </SidebarGroup>
|
|
1184
|
+
* );
|
|
1185
|
+
* }
|
|
1186
|
+
* ```
|
|
1187
|
+
* @since 1.0.0
|
|
1188
|
+
*/
|
|
1189
|
+
function SidebarGroupAction<T extends PolymorphicTarget = "button">(
|
|
1190
|
+
props: SidebarGroupActionProps<T>,
|
|
1191
|
+
): JSX.Element {
|
|
1192
|
+
const [local, rest] = splitProps(props as unknown as Loose, ["as", "class"]);
|
|
1193
|
+
return (
|
|
1194
|
+
<Polymorphic
|
|
1195
|
+
as={local.as ?? "button"}
|
|
1196
|
+
data-slot="sidebar-group-action"
|
|
1197
|
+
data-sidebar="group-action"
|
|
1198
|
+
class={cn(
|
|
1199
|
+
"absolute end-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-none transition-transform group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 motion-reduce:transition-none after:absolute after:-inset-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0 focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-transparent",
|
|
1200
|
+
local.class,
|
|
1201
|
+
)}
|
|
1202
|
+
{...rest}
|
|
1203
|
+
/>
|
|
1204
|
+
);
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
/**
|
|
1208
|
+
* The content of a group, usually a {@link SidebarMenu}. It sets the text size of the group.
|
|
1209
|
+
*
|
|
1210
|
+
* Keyboard and ARIA: no keys and no role of its own; a `div`.
|
|
1211
|
+
*
|
|
1212
|
+
* shadcn/ui differences: `class` replaces `className`.
|
|
1213
|
+
*
|
|
1214
|
+
* @param props - The props: the attributes of the `div`, and `class`.
|
|
1215
|
+
* @returns The content `div`.
|
|
1216
|
+
* @example
|
|
1217
|
+
* ```tsx
|
|
1218
|
+
* import { SidebarGroupContent, SidebarMenu } from "@/components/ui/sidebar";
|
|
1219
|
+
*
|
|
1220
|
+
* export function Menu() {
|
|
1221
|
+
* return (
|
|
1222
|
+
* <SidebarGroupContent>
|
|
1223
|
+
* <SidebarMenu />
|
|
1224
|
+
* </SidebarGroupContent>
|
|
1225
|
+
* );
|
|
1226
|
+
* }
|
|
1227
|
+
* ```
|
|
1228
|
+
* @since 1.0.0
|
|
1229
|
+
*/
|
|
1230
|
+
function SidebarGroupContent(props: ComponentProps<"div">): JSX.Element {
|
|
1231
|
+
const [local, rest] = splitProps(props, ["class", "children"]);
|
|
1232
|
+
return (
|
|
1233
|
+
<div
|
|
1234
|
+
data-slot="sidebar-group-content"
|
|
1235
|
+
data-sidebar="group-content"
|
|
1236
|
+
class={cn("w-full text-sm", local.class)}
|
|
1237
|
+
{...rest}
|
|
1238
|
+
>
|
|
1239
|
+
{local.children}
|
|
1240
|
+
</div>
|
|
1241
|
+
);
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
/**
|
|
1245
|
+
* A list of menu items: a `ul` whose children are {@link SidebarMenuItem}s.
|
|
1246
|
+
*
|
|
1247
|
+
* Keyboard and ARIA: no keys of its own; the items are reached with Tab. The list has the `list`
|
|
1248
|
+
* role, and each item is a `listitem`.
|
|
1249
|
+
*
|
|
1250
|
+
* shadcn/ui differences: `class` replaces `className`.
|
|
1251
|
+
*
|
|
1252
|
+
* @param props - The props: the attributes of the `ul`, and `class`.
|
|
1253
|
+
* @returns The list element.
|
|
1254
|
+
* @example
|
|
1255
|
+
* ```tsx
|
|
1256
|
+
* import {
|
|
1257
|
+
* SidebarMenu,
|
|
1258
|
+
* SidebarMenuButton,
|
|
1259
|
+
* SidebarMenuItem,
|
|
1260
|
+
* } from "@/components/ui/sidebar";
|
|
1261
|
+
*
|
|
1262
|
+
* export function MainMenu() {
|
|
1263
|
+
* return (
|
|
1264
|
+
* <SidebarMenu>
|
|
1265
|
+
* <SidebarMenuItem>
|
|
1266
|
+
* <SidebarMenuButton>Home</SidebarMenuButton>
|
|
1267
|
+
* </SidebarMenuItem>
|
|
1268
|
+
* </SidebarMenu>
|
|
1269
|
+
* );
|
|
1270
|
+
* }
|
|
1271
|
+
* ```
|
|
1272
|
+
* @since 1.0.0
|
|
1273
|
+
*/
|
|
1274
|
+
function SidebarMenu(props: ComponentProps<"ul">): JSX.Element {
|
|
1275
|
+
const [local, rest] = splitProps(props, ["class", "children"]);
|
|
1276
|
+
return (
|
|
1277
|
+
<ul
|
|
1278
|
+
data-slot="sidebar-menu"
|
|
1279
|
+
data-sidebar="menu"
|
|
1280
|
+
class={cn("flex w-full min-w-0 flex-col gap-1", local.class)}
|
|
1281
|
+
{...rest}
|
|
1282
|
+
>
|
|
1283
|
+
{local.children}
|
|
1284
|
+
</ul>
|
|
1285
|
+
);
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
/**
|
|
1289
|
+
* One entry of a menu. It holds the {@link SidebarMenuButton} and its optional action, badge and
|
|
1290
|
+
* sub menu.
|
|
1291
|
+
*
|
|
1292
|
+
* Keyboard and ARIA: no keys and no role of its own; a `li` (a list item).
|
|
1293
|
+
*
|
|
1294
|
+
* shadcn/ui differences: `class` replaces `className`.
|
|
1295
|
+
*
|
|
1296
|
+
* @param props - The props: the attributes of the `li`, and `class`.
|
|
1297
|
+
* @returns The list item.
|
|
1298
|
+
* @example
|
|
1299
|
+
* ```tsx
|
|
1300
|
+
* import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "@/components/ui/sidebar";
|
|
1301
|
+
*
|
|
1302
|
+
* export function Entry() {
|
|
1303
|
+
* return (
|
|
1304
|
+
* <SidebarMenu>
|
|
1305
|
+
* <SidebarMenuItem>
|
|
1306
|
+
* <SidebarMenuButton>Home</SidebarMenuButton>
|
|
1307
|
+
* </SidebarMenuItem>
|
|
1308
|
+
* </SidebarMenu>
|
|
1309
|
+
* );
|
|
1310
|
+
* }
|
|
1311
|
+
* ```
|
|
1312
|
+
* @since 1.0.0
|
|
1313
|
+
* @see {@link SidebarMenuButton}
|
|
1314
|
+
*/
|
|
1315
|
+
function SidebarMenuItem(props: ComponentProps<"li">): JSX.Element {
|
|
1316
|
+
const [local, rest] = splitProps(props, ["class", "children"]);
|
|
1317
|
+
return (
|
|
1318
|
+
<li
|
|
1319
|
+
data-slot="sidebar-menu-item"
|
|
1320
|
+
data-sidebar="menu-item"
|
|
1321
|
+
class={cn("group/menu-item relative", local.class)}
|
|
1322
|
+
{...rest}
|
|
1323
|
+
>
|
|
1324
|
+
{local.children}
|
|
1325
|
+
</li>
|
|
1326
|
+
);
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
/**
|
|
1330
|
+
* The class list of a sidebar menu button, built from `variant` and `size`. Use it to style a link
|
|
1331
|
+
* or another element like a menu button.
|
|
1332
|
+
*
|
|
1333
|
+
* @param props - Optional `variant` (`"default"` or `"outline"`) and `size` (`"default"`, `"sm"` or
|
|
1334
|
+
* `"lg"`), and an optional `class` to add.
|
|
1335
|
+
* @returns The class string.
|
|
1336
|
+
* @example
|
|
1337
|
+
* ```tsx
|
|
1338
|
+
* import { sidebarMenuButtonVariants } from "@/components/ui/sidebar";
|
|
1339
|
+
*
|
|
1340
|
+
* export function OutlineLink() {
|
|
1341
|
+
* return (
|
|
1342
|
+
* <a href="/home" class={sidebarMenuButtonVariants({ variant: "outline", size: "lg" })}>
|
|
1343
|
+
* Home
|
|
1344
|
+
* </a>
|
|
1345
|
+
* );
|
|
1346
|
+
* }
|
|
1347
|
+
* ```
|
|
1348
|
+
* @since 1.0.0
|
|
1349
|
+
* @see {@link SidebarMenuButton}
|
|
1350
|
+
*/
|
|
1351
|
+
const sidebarMenuButtonVariants = cva(
|
|
1352
|
+
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-start text-sm ring-sidebar-ring outline-none transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pe-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground motion-reduce:transition-none forced-colors:data-[active=true]:bg-[Highlight] forced-colors:data-[active=true]:text-[HighlightText] [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-transparent",
|
|
1353
|
+
{
|
|
1354
|
+
variants: {
|
|
1355
|
+
variant: {
|
|
1356
|
+
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
|
1357
|
+
outline:
|
|
1358
|
+
"bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]",
|
|
1359
|
+
},
|
|
1360
|
+
size: {
|
|
1361
|
+
default: "h-8 text-sm",
|
|
1362
|
+
sm: "h-7 text-xs",
|
|
1363
|
+
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
|
|
1364
|
+
},
|
|
1365
|
+
},
|
|
1366
|
+
defaultVariants: {
|
|
1367
|
+
variant: "default",
|
|
1368
|
+
size: "default",
|
|
1369
|
+
},
|
|
1370
|
+
},
|
|
1371
|
+
);
|
|
1372
|
+
|
|
1373
|
+
/**
|
|
1374
|
+
* Own props of {@link SidebarMenuButton}.
|
|
1375
|
+
* @since 1.0.0
|
|
1376
|
+
* @see {@link SidebarMenuButton}
|
|
1377
|
+
*/
|
|
1378
|
+
export interface SidebarMenuButtonExtraProps
|
|
1379
|
+
extends VariantProps<typeof sidebarMenuButtonVariants> {
|
|
1380
|
+
/**
|
|
1381
|
+
* Marks the current page. The button gets `data-active="true"` and is highlighted.
|
|
1382
|
+
* @default false
|
|
1383
|
+
*/
|
|
1384
|
+
readonly isActive?: boolean | undefined;
|
|
1385
|
+
/**
|
|
1386
|
+
* A tooltip shown beside the button while the sidebar is collapsed to icons: a string, or the
|
|
1387
|
+
* props of `TooltipContent` (`children`, `side`...). It names the entry in that state.
|
|
1388
|
+
* @default none (no tooltip)
|
|
1389
|
+
*/
|
|
1390
|
+
readonly tooltip?: string | TooltipContentProps | undefined;
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
/**
|
|
1394
|
+
* Props of {@link SidebarMenuButton}: `isActive`, `tooltip`, `variant` and `size`, and the attributes
|
|
1395
|
+
* of the rendered element. `as` renders another element, such as a link.
|
|
1396
|
+
* @since 1.0.0
|
|
1397
|
+
* @see {@link SidebarMenuButton}
|
|
1398
|
+
*/
|
|
1399
|
+
export type SidebarMenuButtonProps<T extends PolymorphicTarget = "button"> = PolymorphicProps<
|
|
1400
|
+
"button",
|
|
1401
|
+
SidebarMenuButtonExtraProps,
|
|
1402
|
+
T
|
|
1403
|
+
>;
|
|
1404
|
+
|
|
1405
|
+
/**
|
|
1406
|
+
* A menu entry. It renders a `button`, or another element with `as` (for example an `a` for a
|
|
1407
|
+
* link). While the sidebar is collapsed to icons, the `tooltip` shows beside the entry on hover or
|
|
1408
|
+
* focus, and names it.
|
|
1409
|
+
*
|
|
1410
|
+
* Keyboard and ARIA: a native button (or the element `as` renders) that takes focus and activates
|
|
1411
|
+
* with Enter and Space. `isActive` sets `data-active="true"`; the page's current link should also
|
|
1412
|
+
* get `aria-current="page"` from you. The tooltip is shown on hover and keyboard focus only while
|
|
1413
|
+
* the sidebar is collapsed, and it is a tooltip, so it does not replace an accessible name: give an
|
|
1414
|
+
* icon-only entry a text label or `aria-label`.
|
|
1415
|
+
*
|
|
1416
|
+
* shadcn/ui differences: `as` replaces `asChild`, and `class` replaces `className`.
|
|
1417
|
+
*
|
|
1418
|
+
* @param props - The props, see {@link SidebarMenuButtonProps}.
|
|
1419
|
+
* @returns The menu button, or a tooltip wrapping it while `tooltip` is set.
|
|
1420
|
+
* @example
|
|
1421
|
+
* ```tsx
|
|
1422
|
+
* import { InfoIcon } from "@/components/ui/icons";
|
|
1423
|
+
* import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "@/components/ui/sidebar";
|
|
1424
|
+
*
|
|
1425
|
+
* export function DashboardLink() {
|
|
1426
|
+
* return (
|
|
1427
|
+
* <SidebarMenu>
|
|
1428
|
+
* <SidebarMenuItem>
|
|
1429
|
+
* <SidebarMenuButton isActive tooltip="Dashboard" as="a" href="/">
|
|
1430
|
+
* <InfoIcon />
|
|
1431
|
+
* <span>Dashboard</span>
|
|
1432
|
+
* </SidebarMenuButton>
|
|
1433
|
+
* </SidebarMenuItem>
|
|
1434
|
+
* </SidebarMenu>
|
|
1435
|
+
* );
|
|
1436
|
+
* }
|
|
1437
|
+
* ```
|
|
1438
|
+
* @since 1.0.0
|
|
1439
|
+
* @see {@link SidebarMenuItem}
|
|
1440
|
+
*/
|
|
1441
|
+
function SidebarMenuButton<T extends PolymorphicTarget = "button">(
|
|
1442
|
+
props: SidebarMenuButtonProps<T>,
|
|
1443
|
+
): JSX.Element {
|
|
1444
|
+
const [local, rest] = splitProps(props as unknown as Loose & SidebarMenuButtonExtraProps, [
|
|
1445
|
+
"as",
|
|
1446
|
+
"isActive",
|
|
1447
|
+
"variant",
|
|
1448
|
+
"size",
|
|
1449
|
+
"tooltip",
|
|
1450
|
+
"class",
|
|
1451
|
+
]);
|
|
1452
|
+
const sidebar = useSidebar();
|
|
1453
|
+
const side = sidebarSideContext.useOptional();
|
|
1454
|
+
const direction = sidebar.dir;
|
|
1455
|
+
const [hovered, setHovered] = useSignal(false);
|
|
1456
|
+
const collapsed = (): boolean => sidebar.state() === "collapsed" && !sidebar.isMobile();
|
|
1457
|
+
// The tooltip is forced shut while the sidebar is expanded, so the primitive never reports that
|
|
1458
|
+
// it closed; forget the hover then, or it would pop up again at the next collapse.
|
|
1459
|
+
useEffect(() => {
|
|
1460
|
+
if (!collapsed()) setHovered(false);
|
|
1461
|
+
});
|
|
1462
|
+
// The tooltip points into the page: away from the edge the sidebar sits on.
|
|
1463
|
+
const tooltipSide = (): "left" | "right" =>
|
|
1464
|
+
((side?.() ?? "start") === "start") === (direction() === "ltr") ? "right" : "left";
|
|
1465
|
+
const tooltip = (): TooltipContentProps =>
|
|
1466
|
+
typeof local.tooltip === "string"
|
|
1467
|
+
? { children: local.tooltip }
|
|
1468
|
+
: (local.tooltip ?? { children: "" });
|
|
1469
|
+
return (
|
|
1470
|
+
<Show
|
|
1471
|
+
when={local.tooltip !== undefined}
|
|
1472
|
+
fallback={
|
|
1473
|
+
<Polymorphic
|
|
1474
|
+
as={local.as ?? "button"}
|
|
1475
|
+
data-slot="sidebar-menu-button"
|
|
1476
|
+
data-sidebar="menu-button"
|
|
1477
|
+
data-size={local.size ?? "default"}
|
|
1478
|
+
data-active={local.isActive === true ? "true" : "false"}
|
|
1479
|
+
class={cn(
|
|
1480
|
+
sidebarMenuButtonVariants({ variant: local.variant, size: local.size }),
|
|
1481
|
+
local.class,
|
|
1482
|
+
)}
|
|
1483
|
+
{...rest}
|
|
1484
|
+
/>
|
|
1485
|
+
}
|
|
1486
|
+
>
|
|
1487
|
+
<Tooltip open={hovered() && collapsed()} onOpenChange={setHovered}>
|
|
1488
|
+
<TooltipTrigger
|
|
1489
|
+
as={local.as ?? "button"}
|
|
1490
|
+
data-slot="sidebar-menu-button"
|
|
1491
|
+
data-sidebar="menu-button"
|
|
1492
|
+
data-size={local.size ?? "default"}
|
|
1493
|
+
data-active={local.isActive === true ? "true" : "false"}
|
|
1494
|
+
class={cn(
|
|
1495
|
+
sidebarMenuButtonVariants({ variant: local.variant, size: local.size }),
|
|
1496
|
+
local.class,
|
|
1497
|
+
)}
|
|
1498
|
+
{...rest}
|
|
1499
|
+
/>
|
|
1500
|
+
<TooltipContent side={tooltipSide()} align="center" {...tooltip()} />
|
|
1501
|
+
</Tooltip>
|
|
1502
|
+
</Show>
|
|
1503
|
+
);
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
/**
|
|
1507
|
+
* Props of {@link SidebarMenuAction}.
|
|
1508
|
+
* @since 1.0.0
|
|
1509
|
+
* @see {@link SidebarMenuAction}
|
|
1510
|
+
*/
|
|
1511
|
+
export type SidebarMenuActionProps<T extends PolymorphicTarget = "button"> = PolymorphicProps<
|
|
1512
|
+
"button",
|
|
1513
|
+
{
|
|
1514
|
+
/** Only show it while the item is hovered or focused (desktop). */
|
|
1515
|
+
readonly showOnHover?: boolean | undefined;
|
|
1516
|
+
},
|
|
1517
|
+
T
|
|
1518
|
+
>;
|
|
1519
|
+
|
|
1520
|
+
/**
|
|
1521
|
+
* A secondary button at the end of a menu item, such as an options menu. It is hidden while the
|
|
1522
|
+
* sidebar is collapsed to icons.
|
|
1523
|
+
*
|
|
1524
|
+
* Keyboard and ARIA: a `button` (Enter and Space press it). It has no name of its own, so give it an
|
|
1525
|
+
* `aria-label` when it shows only an icon. With `showOnHover` it is hidden on desktop until the item
|
|
1526
|
+
* is hovered or focused, but it stays in the tab order.
|
|
1527
|
+
*
|
|
1528
|
+
* shadcn/ui differences: `as` replaces `asChild`, and `class` replaces `className`.
|
|
1529
|
+
*
|
|
1530
|
+
* @param props - The props: `showOnHover`, `as`, and the attributes of the element.
|
|
1531
|
+
* @returns The action element.
|
|
1532
|
+
* @example
|
|
1533
|
+
* ```tsx
|
|
1534
|
+
* import { EllipsisIcon } from "@/components/ui/icons";
|
|
1535
|
+
* import {
|
|
1536
|
+
* SidebarMenu,
|
|
1537
|
+
* SidebarMenuAction,
|
|
1538
|
+
* SidebarMenuButton,
|
|
1539
|
+
* SidebarMenuItem,
|
|
1540
|
+
* } from "@/components/ui/sidebar";
|
|
1541
|
+
*
|
|
1542
|
+
* export function ProjectRow() {
|
|
1543
|
+
* return (
|
|
1544
|
+
* <SidebarMenu>
|
|
1545
|
+
* <SidebarMenuItem>
|
|
1546
|
+
* <SidebarMenuButton>Website</SidebarMenuButton>
|
|
1547
|
+
* <SidebarMenuAction showOnHover aria-label="Project options">
|
|
1548
|
+
* <EllipsisIcon />
|
|
1549
|
+
* </SidebarMenuAction>
|
|
1550
|
+
* </SidebarMenuItem>
|
|
1551
|
+
* </SidebarMenu>
|
|
1552
|
+
* );
|
|
1553
|
+
* }
|
|
1554
|
+
* ```
|
|
1555
|
+
* @since 1.0.0
|
|
1556
|
+
* @see {@link SidebarMenuButton}
|
|
1557
|
+
*/
|
|
1558
|
+
function SidebarMenuAction<T extends PolymorphicTarget = "button">(
|
|
1559
|
+
props: SidebarMenuActionProps<T>,
|
|
1560
|
+
): JSX.Element {
|
|
1561
|
+
const [local, rest] = splitProps(props as unknown as Loose & { showOnHover?: boolean }, [
|
|
1562
|
+
"as",
|
|
1563
|
+
"class",
|
|
1564
|
+
"showOnHover",
|
|
1565
|
+
]);
|
|
1566
|
+
return (
|
|
1567
|
+
<Polymorphic
|
|
1568
|
+
as={local.as ?? "button"}
|
|
1569
|
+
data-slot="sidebar-menu-action"
|
|
1570
|
+
data-sidebar="menu-action"
|
|
1571
|
+
class={cn(
|
|
1572
|
+
"absolute end-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-none transition-transform peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 motion-reduce:transition-none after:absolute after:-inset-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0 focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-transparent",
|
|
1573
|
+
local.showOnHover === true &&
|
|
1574
|
+
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground data-[state=open]:opacity-100 md:opacity-0",
|
|
1575
|
+
local.class,
|
|
1576
|
+
)}
|
|
1577
|
+
{...rest}
|
|
1578
|
+
/>
|
|
1579
|
+
);
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
/**
|
|
1583
|
+
* A number or short text at the end of a menu item. It is hidden while the sidebar shows icons only,
|
|
1584
|
+
* and it ignores the pointer.
|
|
1585
|
+
*
|
|
1586
|
+
* Keyboard and ARIA: no keys and no role of its own. The badge's text is read as part of the item.
|
|
1587
|
+
*
|
|
1588
|
+
* shadcn/ui differences: `class` replaces `className`.
|
|
1589
|
+
*
|
|
1590
|
+
* @param props - The props: the attributes of the `div`, and `class`.
|
|
1591
|
+
* @returns The badge `div`.
|
|
1592
|
+
* @example
|
|
1593
|
+
* ```tsx
|
|
1594
|
+
* import { SidebarMenu, SidebarMenuBadge, SidebarMenuButton, SidebarMenuItem } from "@/components/ui/sidebar";
|
|
1595
|
+
*
|
|
1596
|
+
* export function InboxEntry() {
|
|
1597
|
+
* return (
|
|
1598
|
+
* <SidebarMenu>
|
|
1599
|
+
* <SidebarMenuItem>
|
|
1600
|
+
* <SidebarMenuButton>Inbox</SidebarMenuButton>
|
|
1601
|
+
* <SidebarMenuBadge>24</SidebarMenuBadge>
|
|
1602
|
+
* </SidebarMenuItem>
|
|
1603
|
+
* </SidebarMenu>
|
|
1604
|
+
* );
|
|
1605
|
+
* }
|
|
1606
|
+
* ```
|
|
1607
|
+
* @since 1.0.0
|
|
1608
|
+
*/
|
|
1609
|
+
function SidebarMenuBadge(props: ComponentProps<"div">): JSX.Element {
|
|
1610
|
+
const [local, rest] = splitProps(props, ["class", "children"]);
|
|
1611
|
+
return (
|
|
1612
|
+
<div
|
|
1613
|
+
data-slot="sidebar-menu-badge"
|
|
1614
|
+
data-sidebar="menu-badge"
|
|
1615
|
+
class={cn(
|
|
1616
|
+
"pointer-events-none absolute end-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 group-data-[collapsible=icon]:hidden",
|
|
1617
|
+
local.class,
|
|
1618
|
+
)}
|
|
1619
|
+
{...rest}
|
|
1620
|
+
>
|
|
1621
|
+
{local.children}
|
|
1622
|
+
</div>
|
|
1623
|
+
);
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
/**
|
|
1627
|
+
* A placeholder row shown while a menu loads. Its text bar is 50% to 90% of the row wide, and the
|
|
1628
|
+
* width is the same on the server and in the browser for the same placeholder.
|
|
1629
|
+
*
|
|
1630
|
+
* Keyboard and ARIA: no keys. The placeholders are decorative (`aria-hidden`, from the
|
|
1631
|
+
* {@link Skeleton}); mark the menu with `aria-busy="true"` while it loads.
|
|
1632
|
+
*
|
|
1633
|
+
* shadcn/ui differences: `class` replaces `className`.
|
|
1634
|
+
*
|
|
1635
|
+
* @param props - The props: `showIcon` (adds an icon placeholder), and the attributes of the `div`.
|
|
1636
|
+
* @returns The placeholder `div`.
|
|
1637
|
+
* @example
|
|
1638
|
+
* ```tsx
|
|
1639
|
+
* import { SidebarMenu, SidebarMenuSkeleton } from "@/components/ui/sidebar";
|
|
1640
|
+
*
|
|
1641
|
+
* export function LoadingMenu() {
|
|
1642
|
+
* return (
|
|
1643
|
+
* <SidebarMenu aria-busy="true">
|
|
1644
|
+
* <SidebarMenuSkeleton showIcon />
|
|
1645
|
+
* <SidebarMenuSkeleton />
|
|
1646
|
+
* </SidebarMenu>
|
|
1647
|
+
* );
|
|
1648
|
+
* }
|
|
1649
|
+
* ```
|
|
1650
|
+
* @since 1.0.0
|
|
1651
|
+
* @see {@link Skeleton}
|
|
1652
|
+
*/
|
|
1653
|
+
function SidebarMenuSkeleton(
|
|
1654
|
+
props: ComponentProps<"div"> & { readonly showIcon?: boolean | undefined },
|
|
1655
|
+
): JSX.Element {
|
|
1656
|
+
const [local, rest] = splitProps(props, ["class", "showIcon"]);
|
|
1657
|
+
const id = useBaseId();
|
|
1658
|
+
// A hash of the id: random widths would differ between the server and the client.
|
|
1659
|
+
const width = (): string => {
|
|
1660
|
+
let hash = 0;
|
|
1661
|
+
for (const character of id()) hash = (hash * 31 + character.charCodeAt(0)) % 41;
|
|
1662
|
+
return `${50 + hash}%`;
|
|
1663
|
+
};
|
|
1664
|
+
return (
|
|
1665
|
+
<div
|
|
1666
|
+
data-slot="sidebar-menu-skeleton"
|
|
1667
|
+
data-sidebar="menu-skeleton"
|
|
1668
|
+
class={cn("flex h-8 items-center gap-2 rounded-md px-2", local.class)}
|
|
1669
|
+
{...rest}
|
|
1670
|
+
>
|
|
1671
|
+
<Show when={local.showIcon === true}>
|
|
1672
|
+
<Skeleton class="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />
|
|
1673
|
+
</Show>
|
|
1674
|
+
<Skeleton
|
|
1675
|
+
class="h-4 max-w-(--skeleton-width) flex-1"
|
|
1676
|
+
data-sidebar="menu-skeleton-text"
|
|
1677
|
+
style={{ "--skeleton-width": width() }}
|
|
1678
|
+
/>
|
|
1679
|
+
</div>
|
|
1680
|
+
);
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
/**
|
|
1684
|
+
* A nested list under a menu item, drawn with a line along its start edge. It is hidden while the
|
|
1685
|
+
* sidebar shows icons only.
|
|
1686
|
+
*
|
|
1687
|
+
* Keyboard and ARIA: no keys of its own. It is a `ul`, and its entries are reached with Tab.
|
|
1688
|
+
*
|
|
1689
|
+
* shadcn/ui differences: `class` replaces `className`.
|
|
1690
|
+
*
|
|
1691
|
+
* @param props - The props: the attributes of the `ul`, and `class`.
|
|
1692
|
+
* @returns The sub menu list.
|
|
1693
|
+
* @example
|
|
1694
|
+
* ```tsx
|
|
1695
|
+
* import {
|
|
1696
|
+
* SidebarMenuSub,
|
|
1697
|
+
* SidebarMenuSubButton,
|
|
1698
|
+
* SidebarMenuSubItem,
|
|
1699
|
+
* } from "@/components/ui/sidebar";
|
|
1700
|
+
*
|
|
1701
|
+
* export function TeamLinks() {
|
|
1702
|
+
* return (
|
|
1703
|
+
* <SidebarMenuSub>
|
|
1704
|
+
* <SidebarMenuSubItem>
|
|
1705
|
+
* <SidebarMenuSubButton as="a" href="/team">
|
|
1706
|
+
* Team
|
|
1707
|
+
* </SidebarMenuSubButton>
|
|
1708
|
+
* </SidebarMenuSubItem>
|
|
1709
|
+
* </SidebarMenuSub>
|
|
1710
|
+
* );
|
|
1711
|
+
* }
|
|
1712
|
+
* ```
|
|
1713
|
+
* @since 1.0.0
|
|
1714
|
+
*/
|
|
1715
|
+
function SidebarMenuSub(props: ComponentProps<"ul">): JSX.Element {
|
|
1716
|
+
const [local, rest] = splitProps(props, ["class", "children"]);
|
|
1717
|
+
return (
|
|
1718
|
+
<ul
|
|
1719
|
+
data-slot="sidebar-menu-sub"
|
|
1720
|
+
data-sidebar="menu-sub"
|
|
1721
|
+
class={cn(
|
|
1722
|
+
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-s border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden rtl:-translate-x-px",
|
|
1723
|
+
local.class,
|
|
1724
|
+
)}
|
|
1725
|
+
{...rest}
|
|
1726
|
+
>
|
|
1727
|
+
{local.children}
|
|
1728
|
+
</ul>
|
|
1729
|
+
);
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
/**
|
|
1733
|
+
* One entry of a {@link SidebarMenuSub}, holding a {@link SidebarMenuSubButton}.
|
|
1734
|
+
*
|
|
1735
|
+
* Keyboard and ARIA: no keys and no role of its own; a `li`.
|
|
1736
|
+
*
|
|
1737
|
+
* shadcn/ui differences: `class` replaces `className`.
|
|
1738
|
+
*
|
|
1739
|
+
* @param props - The props: the attributes of the `li`, and `class`.
|
|
1740
|
+
* @returns The list item.
|
|
1741
|
+
* @example
|
|
1742
|
+
* ```tsx
|
|
1743
|
+
* import { SidebarMenuSub, SidebarMenuSubItem } from "@/components/ui/sidebar";
|
|
1744
|
+
*
|
|
1745
|
+
* export function Nested() {
|
|
1746
|
+
* return (
|
|
1747
|
+
* <SidebarMenuSub>
|
|
1748
|
+
* <SidebarMenuSubItem>Team</SidebarMenuSubItem>
|
|
1749
|
+
* </SidebarMenuSub>
|
|
1750
|
+
* );
|
|
1751
|
+
* }
|
|
1752
|
+
* ```
|
|
1753
|
+
* @since 1.0.0
|
|
1754
|
+
* @see {@link SidebarMenuSubButton}
|
|
1755
|
+
*/
|
|
1756
|
+
function SidebarMenuSubItem(props: ComponentProps<"li">): JSX.Element {
|
|
1757
|
+
const [local, rest] = splitProps(props, ["class", "children"]);
|
|
1758
|
+
return (
|
|
1759
|
+
<li
|
|
1760
|
+
data-slot="sidebar-menu-sub-item"
|
|
1761
|
+
data-sidebar="menu-sub-item"
|
|
1762
|
+
class={cn("group/menu-sub-item relative", local.class)}
|
|
1763
|
+
{...rest}
|
|
1764
|
+
>
|
|
1765
|
+
{local.children}
|
|
1766
|
+
</li>
|
|
1767
|
+
);
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
/**
|
|
1771
|
+
* Props of {@link SidebarMenuSubButton}.
|
|
1772
|
+
* @since 1.0.0
|
|
1773
|
+
* @see {@link SidebarMenuSubButton}
|
|
1774
|
+
*/
|
|
1775
|
+
export type SidebarMenuSubButtonProps<T extends PolymorphicTarget = "a"> = PolymorphicProps<
|
|
1776
|
+
"a",
|
|
1777
|
+
{
|
|
1778
|
+
/**
|
|
1779
|
+
* The text size of the entry: `"md"` or `"sm"`.
|
|
1780
|
+
* @default "md"
|
|
1781
|
+
*/
|
|
1782
|
+
readonly size?: "sm" | "md" | undefined;
|
|
1783
|
+
/**
|
|
1784
|
+
* Marks the current page. The entry gets `data-active="true"` and is highlighted.
|
|
1785
|
+
* @default false
|
|
1786
|
+
*/
|
|
1787
|
+
readonly isActive?: boolean | undefined;
|
|
1788
|
+
},
|
|
1789
|
+
T
|
|
1790
|
+
>;
|
|
1791
|
+
|
|
1792
|
+
/**
|
|
1793
|
+
* A link in a sub menu. It renders an `a`; `as` renders a router link or a `button` instead.
|
|
1794
|
+
*
|
|
1795
|
+
* Keyboard and ARIA: a link that takes focus and follows with Enter. `isActive` sets
|
|
1796
|
+
* `data-active="true"`; give the current page `aria-current="page"` yourself.
|
|
1797
|
+
*
|
|
1798
|
+
* shadcn/ui differences: `as` replaces `asChild`, and `class` replaces `className`.
|
|
1799
|
+
*
|
|
1800
|
+
* @param props - The props, see {@link SidebarMenuSubButtonProps}.
|
|
1801
|
+
* @returns The link element.
|
|
1802
|
+
* @example
|
|
1803
|
+
* ```tsx
|
|
1804
|
+
* import { SidebarMenuSubButton } from "@/components/ui/sidebar";
|
|
1805
|
+
*
|
|
1806
|
+
* export function TeamLink() {
|
|
1807
|
+
* return (
|
|
1808
|
+
* <SidebarMenuSubButton as="a" href="/team" isActive aria-current="page">
|
|
1809
|
+
* Team
|
|
1810
|
+
* </SidebarMenuSubButton>
|
|
1811
|
+
* );
|
|
1812
|
+
* }
|
|
1813
|
+
* ```
|
|
1814
|
+
* @since 1.0.0
|
|
1815
|
+
* @see {@link SidebarMenuSub}
|
|
1816
|
+
*/
|
|
1817
|
+
function SidebarMenuSubButton<T extends PolymorphicTarget = "a">(
|
|
1818
|
+
props: SidebarMenuSubButtonProps<T>,
|
|
1819
|
+
): JSX.Element {
|
|
1820
|
+
const [local, rest] = splitProps(
|
|
1821
|
+
props as unknown as Loose & { size?: "sm" | "md"; isActive?: boolean },
|
|
1822
|
+
["as", "size", "isActive", "class"],
|
|
1823
|
+
);
|
|
1824
|
+
return (
|
|
1825
|
+
<Polymorphic
|
|
1826
|
+
as={local.as ?? "a"}
|
|
1827
|
+
data-slot="sidebar-menu-sub-button"
|
|
1828
|
+
data-sidebar="menu-sub-button"
|
|
1829
|
+
data-size={local.size ?? "md"}
|
|
1830
|
+
data-active={local.isActive === true ? "true" : "false"}
|
|
1831
|
+
class={cn(
|
|
1832
|
+
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-none group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground rtl:translate-x-px forced-colors:data-[active=true]:bg-[Highlight] forced-colors:data-[active=true]:text-[HighlightText] [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-transparent",
|
|
1833
|
+
local.size === "sm" ? "text-xs" : "text-sm",
|
|
1834
|
+
local.class,
|
|
1835
|
+
)}
|
|
1836
|
+
{...rest}
|
|
1837
|
+
/>
|
|
1838
|
+
);
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
export {
|
|
1842
|
+
readSidebarCookie,
|
|
1843
|
+
SIDEBAR_COOKIE_NAME,
|
|
1844
|
+
Sidebar,
|
|
1845
|
+
SidebarContent,
|
|
1846
|
+
SidebarFooter,
|
|
1847
|
+
SidebarGroup,
|
|
1848
|
+
SidebarGroupAction,
|
|
1849
|
+
SidebarGroupContent,
|
|
1850
|
+
SidebarGroupLabel,
|
|
1851
|
+
SidebarHeader,
|
|
1852
|
+
SidebarInput,
|
|
1853
|
+
SidebarInset,
|
|
1854
|
+
SidebarMenu,
|
|
1855
|
+
SidebarMenuAction,
|
|
1856
|
+
SidebarMenuBadge,
|
|
1857
|
+
SidebarMenuButton,
|
|
1858
|
+
SidebarMenuItem,
|
|
1859
|
+
SidebarMenuSkeleton,
|
|
1860
|
+
SidebarMenuSub,
|
|
1861
|
+
SidebarMenuSubButton,
|
|
1862
|
+
SidebarMenuSubItem,
|
|
1863
|
+
SidebarProvider,
|
|
1864
|
+
SidebarRail,
|
|
1865
|
+
SidebarSeparator,
|
|
1866
|
+
SidebarTrigger,
|
|
1867
|
+
sidebarCookie,
|
|
1868
|
+
sidebarMenuButtonVariants,
|
|
1869
|
+
useSidebar,
|
|
1870
|
+
};
|