@shadow-js/ui 0.0.0-stage → 1.0.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.
Files changed (190) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/LICENSE +21 -0
  3. package/README.md +598 -2
  4. package/dist/cli.mjs +2281 -0
  5. package/dist/dev/cli/schema.d.ts +71 -0
  6. package/dist/dev/index.d.ts +6 -0
  7. package/dist/dev/index.js +5 -0
  8. package/dist/dev/locales/ar.d.ts +139 -0
  9. package/dist/dev/locales/ar.js +127 -0
  10. package/dist/dev/locales/ar.js.map +1 -0
  11. package/dist/dev/locales/en.d.ts +139 -0
  12. package/dist/dev/locales/en.js +127 -0
  13. package/dist/dev/locales/en.js.map +1 -0
  14. package/dist/dev/registry/utils/utils.d.ts +27 -0
  15. package/dist/dev/registry/utils/utils.js +26 -0
  16. package/dist/dev/registry/utils/utils.js.map +1 -0
  17. package/dist/dev/theme/theme-logic.d.ts +185 -0
  18. package/dist/dev/theme/theme-logic.js +183 -0
  19. package/dist/dev/theme/theme-logic.js.map +1 -0
  20. package/dist/dev/theme/theme-provider.d.ts +88 -0
  21. package/dist/dev/theme/theme-provider.js +149 -0
  22. package/dist/dev/theme/theme-provider.js.map +1 -0
  23. package/dist/prod/index.js +1 -0
  24. package/dist/prod/locales/ar.js +2 -0
  25. package/dist/prod/locales/ar.js.map +1 -0
  26. package/dist/prod/locales/en.js +2 -0
  27. package/dist/prod/locales/en.js.map +1 -0
  28. package/dist/prod/registry/utils/utils.js +2 -0
  29. package/dist/prod/registry/utils/utils.js.map +1 -0
  30. package/dist/prod/theme/theme-logic.js +2 -0
  31. package/dist/prod/theme/theme-logic.js.map +1 -0
  32. package/dist/prod/theme/theme-provider.js +2 -0
  33. package/dist/prod/theme/theme-provider.js.map +1 -0
  34. package/package.json +102 -4
  35. package/registry/accordion/accordion.tsx +210 -0
  36. package/registry/accordion/item.json +4 -0
  37. package/registry/alert/alert.tsx +200 -0
  38. package/registry/alert/item.json +4 -0
  39. package/registry/alert-dialog/alert-dialog.tsx +530 -0
  40. package/registry/alert-dialog/item.json +4 -0
  41. package/registry/aspect-ratio/aspect-ratio.tsx +71 -0
  42. package/registry/aspect-ratio/item.json +4 -0
  43. package/registry/attachment/attachment.tsx +459 -0
  44. package/registry/attachment/item.json +4 -0
  45. package/registry/avatar/avatar.tsx +385 -0
  46. package/registry/avatar/item.json +4 -0
  47. package/registry/badge/badge.tsx +108 -0
  48. package/registry/badge/item.json +4 -0
  49. package/registry/breadcrumb/breadcrumb.tsx +354 -0
  50. package/registry/breadcrumb/item.json +4 -0
  51. package/registry/bubble/bubble.tsx +260 -0
  52. package/registry/bubble/item.json +4 -0
  53. package/registry/button/button.tsx +125 -0
  54. package/registry/button/item.json +4 -0
  55. package/registry/button-group/button-group.tsx +189 -0
  56. package/registry/button-group/item.json +4 -0
  57. package/registry/calendar/calendar.tsx +348 -0
  58. package/registry/calendar/item.json +4 -0
  59. package/registry/card/card.tsx +311 -0
  60. package/registry/card/item.json +4 -0
  61. package/registry/carousel/carousel.tsx +851 -0
  62. package/registry/carousel/item.json +4 -0
  63. package/registry/chart/chart.tsx +1956 -0
  64. package/registry/chart/item.json +4 -0
  65. package/registry/chart-scale/chart-scale.ts +670 -0
  66. package/registry/chart-scale/item.json +5 -0
  67. package/registry/checkbox/checkbox.tsx +79 -0
  68. package/registry/checkbox/item.json +4 -0
  69. package/registry/code-block/code-block.tsx +470 -0
  70. package/registry/code-block/item.json +4 -0
  71. package/registry/collapsible/collapsible.tsx +117 -0
  72. package/registry/collapsible/item.json +4 -0
  73. package/registry/combobox/combobox.tsx +977 -0
  74. package/registry/combobox/item.json +4 -0
  75. package/registry/command/command.tsx +757 -0
  76. package/registry/command/item.json +4 -0
  77. package/registry/context-menu/context-menu.tsx +697 -0
  78. package/registry/context-menu/item.json +4 -0
  79. package/registry/data-table/data-table.tsx +437 -0
  80. package/registry/data-table/item.json +4 -0
  81. package/registry/date-picker/date-picker.tsx +349 -0
  82. package/registry/date-picker/item.json +4 -0
  83. package/registry/dialog/dialog.tsx +412 -0
  84. package/registry/dialog/item.json +4 -0
  85. package/registry/direction/direction.tsx +88 -0
  86. package/registry/direction/item.json +4 -0
  87. package/registry/drawer/drawer.tsx +787 -0
  88. package/registry/drawer/item.json +4 -0
  89. package/registry/dropdown-menu/dropdown-menu.tsx +692 -0
  90. package/registry/dropdown-menu/item.json +4 -0
  91. package/registry/empty/empty.tsx +240 -0
  92. package/registry/empty/item.json +4 -0
  93. package/registry/field/field.tsx +624 -0
  94. package/registry/field/item.json +4 -0
  95. package/registry/form/form.tsx +1112 -0
  96. package/registry/form/item.json +4 -0
  97. package/registry/hover-card/hover-card.tsx +144 -0
  98. package/registry/hover-card/item.json +4 -0
  99. package/registry/icons/icons.tsx +1691 -0
  100. package/registry/icons/item.json +4 -0
  101. package/registry/input/input.tsx +43 -0
  102. package/registry/input/item.json +4 -0
  103. package/registry/input-group/input-group.tsx +315 -0
  104. package/registry/input-group/item.json +4 -0
  105. package/registry/input-otp/input-otp.tsx +329 -0
  106. package/registry/input-otp/item.json +4 -0
  107. package/registry/item/item.json +4 -0
  108. package/registry/item/item.tsx +439 -0
  109. package/registry/kbd/item.json +4 -0
  110. package/registry/kbd/kbd.tsx +65 -0
  111. package/registry/label/item.json +4 -0
  112. package/registry/label/label.tsx +60 -0
  113. package/registry/marker/item.json +4 -0
  114. package/registry/marker/marker.tsx +175 -0
  115. package/registry/menubar/item.json +4 -0
  116. package/registry/menubar/menubar.tsx +694 -0
  117. package/registry/message/item.json +4 -0
  118. package/registry/message/message.tsx +280 -0
  119. package/registry/message-scroller/item.json +4 -0
  120. package/registry/message-scroller/message-scroller.tsx +262 -0
  121. package/registry/native-select/item.json +4 -0
  122. package/registry/native-select/native-select.tsx +113 -0
  123. package/registry/navigation-menu/item.json +4 -0
  124. package/registry/navigation-menu/navigation-menu.tsx +362 -0
  125. package/registry/pagination/item.json +4 -0
  126. package/registry/pagination/pagination.tsx +460 -0
  127. package/registry/password-input/item.json +5 -0
  128. package/registry/password-input/password-input.tsx +494 -0
  129. package/registry/popover/item.json +4 -0
  130. package/registry/popover/popover.tsx +284 -0
  131. package/registry/progress/item.json +4 -0
  132. package/registry/progress/progress.tsx +190 -0
  133. package/registry/questionnaire/item.json +4 -0
  134. package/registry/questionnaire/questionnaire.tsx +569 -0
  135. package/registry/radio-group/item.json +4 -0
  136. package/registry/radio-group/radio-group.tsx +101 -0
  137. package/registry/resizable/item.json +4 -0
  138. package/registry/resizable/resizable.tsx +455 -0
  139. package/registry/scroll-area/item.json +4 -0
  140. package/registry/scroll-area/scroll-area.tsx +149 -0
  141. package/registry/select/item.json +4 -0
  142. package/registry/select/select.tsx +571 -0
  143. package/registry/separator/item.json +4 -0
  144. package/registry/separator/separator.tsx +52 -0
  145. package/registry/sheet/item.json +4 -0
  146. package/registry/sheet/sheet.tsx +476 -0
  147. package/registry/sidebar/item.json +4 -0
  148. package/registry/sidebar/sidebar.tsx +1870 -0
  149. package/registry/skeleton/item.json +4 -0
  150. package/registry/skeleton/skeleton.tsx +47 -0
  151. package/registry/slider/item.json +4 -0
  152. package/registry/slider/slider.tsx +149 -0
  153. package/registry/spinner/item.json +4 -0
  154. package/registry/spinner/spinner.tsx +51 -0
  155. package/registry/switch/item.json +4 -0
  156. package/registry/switch/switch.tsx +93 -0
  157. package/registry/table/item.json +4 -0
  158. package/registry/table/table.tsx +373 -0
  159. package/registry/tabs/item.json +4 -0
  160. package/registry/tabs/tabs.tsx +209 -0
  161. package/registry/textarea/item.json +4 -0
  162. package/registry/textarea/textarea.tsx +51 -0
  163. package/registry/toast/item.json +4 -0
  164. package/registry/toast/toast.tsx +949 -0
  165. package/registry/toggle/item.json +4 -0
  166. package/registry/toggle/toggle.tsx +110 -0
  167. package/registry/toggle-group/item.json +4 -0
  168. package/registry/toggle-group/toggle-group.tsx +189 -0
  169. package/registry/tooltip/item.json +4 -0
  170. package/registry/tooltip/tooltip.tsx +169 -0
  171. package/registry/trace/item.json +4 -0
  172. package/registry/trace/trace.tsx +356 -0
  173. package/registry/ui-strings/item.json +5 -0
  174. package/registry/ui-strings/ui-strings.tsx +454 -0
  175. package/registry/use-highlighted-code/item.json +5 -0
  176. package/registry/use-highlighted-code/use-highlighted-code.tsx +145 -0
  177. package/registry/use-mobile/item.json +5 -0
  178. package/registry/use-mobile/use-mobile.ts +46 -0
  179. package/registry/utils/item.json +5 -0
  180. package/registry/utils/utils.ts +38 -0
  181. package/registry.json +3274 -0
  182. package/styles/animations.css +321 -0
  183. package/styles/theme-core.css +55 -0
  184. package/styles/theme.css +12 -0
  185. package/styles/themes/gray.css +69 -0
  186. package/styles/themes/neutral.css +69 -0
  187. package/styles/themes/slate.css +69 -0
  188. package/styles/themes/stone.css +69 -0
  189. package/styles/themes/zinc.css +69 -0
  190. 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
+ };