diff --git a/docs/public/registry/index.json b/docs/public/registry/index.json index 4a13ab9..3b933bc 100644 --- a/docs/public/registry/index.json +++ b/docs/public/registry/index.json @@ -311,6 +311,16 @@ ], "type": "components:ui" }, + { + "name": "sidebar", + "dependencies": [ + "@kobalte/core" + ], + "files": [ + "ui/sidebar.tsx" + ], + "type": "components:ui" + }, { "name": "skeleton", "files": [ diff --git a/docs/scripts/utils/index.ts b/docs/scripts/utils/index.ts index 41c7204..592b4a3 100644 --- a/docs/scripts/utils/index.ts +++ b/docs/scripts/utils/index.ts @@ -183,6 +183,12 @@ const ui = [ dependencies: ["@kobalte/core"], files: ["ui/sheet.tsx"], }, + { + name: "sidebar", + type: "components:ui", + dependencies: ["@kobalte/core"], + files: ["ui/sidebar.tsx"], + }, { name: "skeleton", type: "components:ui", @@ -561,6 +567,11 @@ const example = [ type: "components:example", files: ["examples/sheet-side.tsx"], }, + { + name: "sidebar-demo", + type: "components:example", + files: ["examples/sidebar-demo.tsx"], + }, { name: "skeleton-demo", type: "components:example", diff --git a/docs/src/__registry__/index.js b/docs/src/__registry__/index.js index 68bb4d9..178f2e3 100644 --- a/docs/src/__registry__/index.js +++ b/docs/src/__registry__/index.js @@ -376,6 +376,12 @@ export const Index = { registryDependencies: undefined, component: lazy(() => import("../examples/sheet-side")) }, + "sidebar-demo": { + name: "sidebar-demo", + type: "components:example", + registryDependencies: undefined, + component: lazy(() => import("../examples/sidebar-demo")) + }, "skeleton-demo": { name: "skeleton-demo", type: "components:example", diff --git a/docs/src/config/docs.ts b/docs/src/config/docs.ts index ad03f4e..9e7a778 100644 --- a/docs/src/config/docs.ts +++ b/docs/src/config/docs.ts @@ -244,6 +244,12 @@ export const docsConfig: TDocsConfig = { href: "/docs/components/sheet", items: [], }, + { + title: "Sidebar", + href: "/docs/components/sidebar", + items: [], + label: "New", + }, { title: "Skeleton", href: "/docs/components/skeleton", diff --git a/docs/src/data/docs/cli.mdx b/docs/src/data/docs/cli.mdx index 50ac753..99f44d2 100644 --- a/docs/src/data/docs/cli.mdx +++ b/docs/src/data/docs/cli.mdx @@ -86,6 +86,7 @@ You will be presented with a list of components to choose from: │ ◻ select │ ◻ separator │ ◻ sheet +│ ◻ sidebar │ ◻ skeleton │ ◻ switch │ ◻ table diff --git a/docs/src/data/docs/components/sidebar.mdx b/docs/src/data/docs/components/sidebar.mdx new file mode 100644 index 0000000..c770868 --- /dev/null +++ b/docs/src/data/docs/components/sidebar.mdx @@ -0,0 +1,100 @@ +--- +title: Sidebar +description: A composable, themeable and customizable sidebar component. +component: true +--- + +import ComponentInstallation from "@/components/component-installation"; +import ComponentPreview from "@/components/component-preview"; + + + + ```tsx file=/src/examples/sidebar-demo.tsx + ``` + + + +## Installation + + + + + ```bash + npx shadcn-solid@latest add sidebar + ``` + + + + + Install the following dependencies: + ```bash + npm install @kobalte/core + ``` + + Copy and paste the following code into your project: + ```tsx file=/../packages/tailwindcss/ui/sidebar.tsx + ``` + + + + + + Install the following dependencies: + ```bash + npm install @kobalte/core + ``` + + Copy and paste the following code into your project: + ```tsx file=/../packages/unocss/ui/sidebar.tsx + ``` + + + + + +## Usage + +```tsx showLineNumbers title="src/app.tsx" + +export default function App() { +return ( + ( + + My App + {props.children} + + )} +> + + +); +} +``` + +```tsx showLineNumbers title="components/app-sidebar.tsx" +import { +Sidebar, +SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarHeader, +} from "@/components/ui/sidebar" + +export function AppSidebar() { +return ( + + + + + + + + +) +} +``` + +## Examples + +### \ No newline at end of file diff --git a/docs/src/data/docs/installation/solid-start.mdx b/docs/src/data/docs/installation/solid-start.mdx index b52b28f..4c69602 100644 --- a/docs/src/data/docs/installation/solid-start.mdx +++ b/docs/src/data/docs/installation/solid-start.mdx @@ -17,7 +17,7 @@ pnpm create solid@latest ### Path Aliases -I'm use the `@` alias to make it easier to import your components. This is how you can configure it: +I use the `@` alias to make it easier to import your components. This is how you can configure it: ```json title="tsconfig.json"{3-6} { diff --git a/docs/src/examples/sidebar-demo.tsx b/docs/src/examples/sidebar-demo.tsx new file mode 100644 index 0000000..9ae6fc7 --- /dev/null +++ b/docs/src/examples/sidebar-demo.tsx @@ -0,0 +1,165 @@ +import { + Sidebar, + SidebarContent, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarInset, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarProvider, + SidebarTrigger, +} from "@repo/tailwindcss/ui/sidebar"; +import { For, JSX } from "solid-js"; + +type MenuItem = { + title: string; + url: string; + icon: () => JSX.Element; +}; + +const items: MenuItem[] = [ + { + title: "Home", + url: "#", + icon: () => ( + + + + + ), + }, + { + title: "Inbox", + url: "#", + icon: () => ( + + + + + ), + }, + { + title: "Calendar", + url: "#", + icon: () => ( + + + + + + + ), + }, + { + title: "Search", + url: "#", + icon: () => ( + + + + + ), + }, + { + title: "Settings", + url: "#", + icon: () => ( + + + + + ), + }, +]; + +export default function AppSidebar() { + return ( + + + + + Application + + + + {(item) => ( + + + + + {item.title} + + + + )} + + + + + + + +
+ +
+
+
+ ); +} diff --git a/packages/tailwindcss/ui/sidebar.tsx b/packages/tailwindcss/ui/sidebar.tsx new file mode 100644 index 0000000..6b089ce --- /dev/null +++ b/packages/tailwindcss/ui/sidebar.tsx @@ -0,0 +1,805 @@ +import { Button } from "@repo/tailwindcss/ui/button"; +import { Separator } from "@repo/tailwindcss/ui/separator"; +import { Sheet, SheetContent } from "@repo/tailwindcss/ui/sheet"; +import { TextField } from "@repo/tailwindcss/ui/textfield"; + +import { cn } from "@/libs/cn"; +import { Skeleton } from "@repo/tailwindcss/ui/skeleton"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@repo/tailwindcss/ui/tooltip"; +import { type VariantProps, cva } from "class-variance-authority"; +import { + type Accessor, + type ComponentProps, + type JSX, + Show, + createContext, + createMemo, + createSignal, + mergeProps, + onCleanup, + onMount, + splitProps, + useContext, +} from "solid-js"; + +const SIDEBAR_COOKIE_NAME = "sidebar:state"; +const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; +const SIDEBAR_WIDTH = "16rem"; +const SIDEBAR_WIDTH_MOBILE = "18rem"; +const SIDEBAR_WIDTH_ICON = "3rem"; +const SIDEBAR_KEYBOARD_SHORTCUT = "b"; +const MOBILE_BREAKPOINT = 768; + +type SidebarContext = { + state: "expanded" | "collapsed"; + open: boolean; + setOpen: (open: boolean) => void; + openMobile: boolean; + setOpenMobile: (open: boolean) => void; + isMobile: boolean; + toggleSidebar: () => void; +}; + +const SidebarContext = createContext | null>(null); + +function useSidebar() { + const context = useContext(SidebarContext); + + if (!context) { + throw new Error("useSidebar must be used within a SidebarProvider"); + } + + return context; +} + +export function useIsMobile() { + const [isMobile, setIsMobile] = createSignal(false); + + onMount(() => { + const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); + const onChange = () => { + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); + }; + + mql.addEventListener("change", onChange); + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); + + onCleanup(() => mql.removeEventListener("change", onChange)); + }); + + return isMobile; +} + +type SidebarProviderProps = { + defaultOpen?: boolean; + open?: boolean; + onOpenChange?: (open: boolean) => void; + children: JSX.Element; +} & ComponentProps<"div">; + +const SidebarProvider = (props: SidebarProviderProps) => { + const isMobile = useIsMobile(); + const [openMobile, setOpenMobile] = createSignal(false); + + const [local, rest] = splitProps(props, [ + "open", + "class", + "style", + "defaultOpen", + "onOpenChange", + "children", + ]); + + // This is the internal state of the sidebar. + // We use local.open and local.onOpenChange for control from outside the component. + const [_open, _setOpen] = createSignal(local.defaultOpen ?? true); + const open = () => local.open ?? _open(); + + const setOpen = (value: boolean | ((value: boolean) => boolean)) => { + const openState = typeof value === "function" ? value(open()) : open(); + _setOpen(openState); + local.onOpenChange?.(open()); + + document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; + }; + + const toggleSidebar = () => { + isMobile() ? setOpenMobile(!openMobile()) : setOpen(!open()); + }; + + // Adds a keyboard shortcut to toggle the sidebar + onMount(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if ( + event.key === SIDEBAR_KEYBOARD_SHORTCUT && + (event.metaKey || event.ctrlKey) + ) { + event.preventDefault(); + toggleSidebar(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + onCleanup(() => window.removeEventListener("keydown", handleKeyDown)); + }); + + // We add a state so that we can do data-state="expanded" or "collapsed". + // This makes it easier to style the sidebar with Tailwind classes. + const state = open() ? "expanded" : "collapsed"; + + const contextValue = createMemo(() => ({ + state, + open: open(), + setOpen, + isMobile: isMobile(), + openMobile: openMobile(), + setOpenMobile, + toggleSidebar, + })); + + return ( + +
+ {local.children} +
+
+ ); +}; + +type SidebarProps = { + side?: "left" | "right"; + variant?: "sidebar" | "floating" | "inset"; + collapsible?: "offcanvas" | "icon" | "none"; + children: JSX.Element; +} & ComponentProps<"div">; + +const Sidebar = (props: SidebarProps) => { + const mergedProps = mergeProps( + { + side: "left" as const, + variant: "sidebar" as const, + collapsible: "offcanvas" as const, + }, + props, + ); + + const [local, rest] = splitProps< + SidebarProps, + [["class", "children", "side", "variant", "collapsible"]] + >(mergedProps, ["class", "children", "side", "variant", "collapsible"]); + + const context = useSidebar(); + + if (local.collapsible === "none") { + return ( +
+ {local.children} +
+ ); + } + + if (context().isMobile) { + return ( + + +
{local.children}
+
+
+ ); + } + + return ( +