diff --git a/apps/examples/.gitignore b/apps/examples/.gitignore new file mode 100644 index 000000000..cccfd3167 --- /dev/null +++ b/apps/examples/.gitignore @@ -0,0 +1,44 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# fumadocs +.source \ No newline at end of file diff --git a/apps/examples/README.md b/apps/examples/README.md new file mode 100644 index 000000000..1a873faca --- /dev/null +++ b/apps/examples/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun run dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/apps/examples/app/calcom-shell/page.tsx b/apps/examples/app/calcom-shell/page.tsx new file mode 100644 index 000000000..0676a333c --- /dev/null +++ b/apps/examples/app/calcom-shell/page.tsx @@ -0,0 +1,18 @@ +import { AppSidebar } from "@/components/app-sidebar"; +import { EventTypes } from "@/components/event-types"; +import { MobileFooter } from "@/components/mobile-footer"; +import { MobileHeader } from "@/components/mobile-header"; +import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; + +export default function Page() { + return ( + + + + + + + + + ); +} diff --git a/apps/examples/app/favicon.ico b/apps/examples/app/favicon.ico new file mode 100644 index 000000000..b0a74387b Binary files /dev/null and b/apps/examples/app/favicon.ico differ diff --git a/apps/examples/app/globals.css b/apps/examples/app/globals.css new file mode 100644 index 000000000..2867b8cf3 --- /dev/null +++ b/apps/examples/app/globals.css @@ -0,0 +1,29 @@ +@import "@coss/ui/globals.css"; + +:root { + --sidebar-foreground: color-mix( + in srgb, + var(--color-zinc-800) 80%, + var(--sidebar) + ); + --text-xs: 0.8125rem; + --text-xs--line-height: 1rem; + --text-sm: 0.9375rem; + --text-sm--line-height: 1.25rem; + --text-base: 1.0625rem; + --text-base--line-height: 1.5rem; + --text-lg: 1.1875rem; + --text-lg--line-height: 1.75rem; + --font-weight-normal: 350; + --font-weight-medium: 450; + --font-weight-semibold: 550; + --font-weight-bold: 650; +} + +.dark { + --sidebar-foreground: color-mix( + in srgb, + var(--color-zinc-200) 80%, + var(--sidebar) + ); +} diff --git a/apps/examples/app/layout.tsx b/apps/examples/app/layout.tsx new file mode 100644 index 000000000..b846f7819 --- /dev/null +++ b/apps/examples/app/layout.tsx @@ -0,0 +1,30 @@ +import "./globals.css"; + +import { ToastProvider } from "@coss/ui/components/toast"; +import { fontHeading, fontSans } from "@coss/ui/fonts"; +import { ThemeProvider } from "@coss/ui/shared/theme-provider"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + description: "coss.com - the everything but AI company", + metadataBase: new URL("https://coss.com"), + title: "coss.com", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + + {children} + + + + ); +} diff --git a/apps/examples/components/app-sidebar.tsx b/apps/examples/components/app-sidebar.tsx new file mode 100644 index 000000000..a07a9dfaa --- /dev/null +++ b/apps/examples/components/app-sidebar.tsx @@ -0,0 +1,42 @@ +"use client"; + +import type * as React from "react"; +import { HeaderActions } from "@/components/header-actions"; +import { Logo } from "@/components/logo"; +import { NavMain } from "@/components/nav-main"; +import { NavSecondary } from "@/components/nav-secondary"; +import { + Sidebar, + SidebarContent, + SidebarHeader, + SidebarMenuButton, +} from "@/components/ui/sidebar"; +import { navFooterItems, navMainItems } from "@/lib/navigation-data"; + +export function AppSidebar({ + variant, + ...props +}: React.ComponentProps & { + variant?: never; +}) { + return ( + + +
+ } + /> + +
+
+ + + +
+ © 2025 Cal.com, Inc. v.5.9.6-h-2701b4d +
+
+
+ ); +} diff --git a/apps/examples/components/event-types.tsx b/apps/examples/components/event-types.tsx new file mode 100644 index 000000000..985355511 --- /dev/null +++ b/apps/examples/components/event-types.tsx @@ -0,0 +1,336 @@ +"use client"; + +import { Badge } from "@coss/ui/components/badge"; +import { Button } from "@coss/ui/components/button"; +import { Card, CardPanel } from "@coss/ui/components/card"; +import { Group, GroupSeparator } from "@coss/ui/components/group"; +import { + InputGroup, + InputGroupAddon, + InputGroupInput, +} from "@coss/ui/components/input-group"; +import { + Menu, + MenuCheckboxItem, + MenuItem, + MenuPopup, + MenuSeparator, + MenuTrigger, +} from "@coss/ui/components/menu"; +import { Switch } from "@coss/ui/components/switch"; +import { + Tooltip, + TooltipCreateHandle, + TooltipPopup, + TooltipProvider, + TooltipTrigger, +} from "@coss/ui/components/tooltip"; +import { + ClockIcon, + EllipsisIcon, + EyeIcon, + Link2Icon, + PlusIcon, + SearchIcon, +} from "lucide-react"; +import { useState } from "react"; + +const tooltipHandle = TooltipCreateHandle(); + +const eventTypes = [ + { + duration: "15m", + enabled: true, + hidden: false, + id: 1, + path: "/pasquale/15min", + title: "15 Min Meeting", + }, + { + duration: "30m", + enabled: true, + hidden: false, + id: 2, + path: "/pasquale/30min", + title: "30 Min Meeting", + }, + { + duration: "15m", + enabled: false, + hidden: true, + id: 3, + path: "/pasquale/secret", + title: "Secret Meeting", + }, + { + duration: "15m", + enabled: false, + hidden: true, + id: 4, + path: "/pasquale/secret", + title: "Secret Meeting", + }, + { + duration: "15m", + enabled: false, + hidden: true, + id: 5, + path: "/pasquale/secret", + title: "Secret Meeting", + }, + { + duration: "15m", + enabled: false, + hidden: true, + id: 6, + path: "/pasquale/secret", + title: "Secret Meeting", + }, + { + duration: "15m", + enabled: false, + hidden: true, + id: 7, + path: "/pasquale/secret", + title: "Secret Meeting", + }, + { + duration: "15m", + enabled: false, + hidden: true, + id: 8, + path: "/pasquale/secret", + title: "Secret Meeting", + }, + { + duration: "15m", + enabled: false, + hidden: true, + id: 9, + path: "/pasquale/secret", + title: "Secret Meeting", + }, + { + duration: "15m", + enabled: false, + hidden: true, + id: 10, + path: "/pasquale/secret", + title: "Secret Meeting", + }, +]; + +export function EventTypes() { + const [hiddenStates, setHiddenStates] = useState>( + Object.fromEntries(eventTypes.map((et) => [et.id, et.hidden])), + ); + + const handleHiddenToggle = (id: number, checked: boolean) => { + setHiddenStates((prev) => ({ + ...prev, + [id]: checked, + })); + }; + + return ( + + {/* Header */} +
+
+

Event Types

+

+ Create events to share for people to book on your calendar. +

+
+ +
+ + {/* Search */} +
+ + + + + + +
+ +
+ {eventTypes.map((eventType) => { + const isHidden = hiddenStates[eventType.id]; + return ( + + +
+ {/* Content */} +
+
+

+ + {eventType.title} + +

+ + + {eventType.duration} + +
+

+ {eventType.path} +

+
+ {/* Actions */} +
+
+ {isHidden ? ( + + Hidden + + ) : null} + + + handleHiddenToggle(eventType.id, !checked) + } + /> + } + /> + + {isHidden ? "Show on profile" : "Hide from profile"} + + +
+ {/* Desktop: Group of buttons */} + + "Preview"} + render={ + + } + /> + + "Copy link"} + render={ + + } + /> + + + "More options"} + render={ + + } + /> + } + /> + + Edit + Duplicate + Embed + + Delete + + + + {/* Mobile: Single menu button with all actions */} + + + + + } + /> + + Preview + Copy link to event + Share + Edit + Duplicate + + Delete + + { + handleHiddenToggle(eventType.id, !checked); + }} + > + Show on profile + + + +
+
+
+
+ ); + })} +
+ + {/* No more results */} +
+ No more results +
+ + + {({ payload: Payload }) => ( + {Payload !== undefined && } + )} + +
+ ); +} diff --git a/apps/examples/components/header-actions.tsx b/apps/examples/components/header-actions.tsx new file mode 100644 index 000000000..484c83eb5 --- /dev/null +++ b/apps/examples/components/header-actions.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { + Avatar, + AvatarFallback, + AvatarImage, +} from "@coss/ui/components/avatar"; +import { SearchIcon } from "lucide-react"; +import Link from "next/link"; +import { SidebarMenuButton } from "@/components/ui/sidebar"; +import { UserMenu } from "@/components/user-menu"; + +export function HeaderActions() { + return ( +
+ + + + + + + CC + + + } + /> + +
+ ); +} diff --git a/apps/examples/components/logo.tsx b/apps/examples/components/logo.tsx new file mode 100644 index 000000000..f38187b8b --- /dev/null +++ b/apps/examples/components/logo.tsx @@ -0,0 +1,11 @@ +import Link from "next/link"; + +export function Logo({ ...props }) { + return ( + +

+ Cal.com +

+ + ); +} diff --git a/apps/examples/components/mobile-footer.tsx b/apps/examples/components/mobile-footer.tsx new file mode 100644 index 000000000..679042bb8 --- /dev/null +++ b/apps/examples/components/mobile-footer.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { Button } from "@coss/ui/components/button"; +import { + Menu, + MenuGroup, + MenuItem, + MenuPopup, + MenuSeparator, + MenuTrigger, +} from "@coss/ui/components/menu"; +import { cn } from "@coss/ui/lib/utils"; +import { EllipsisIcon, PlusIcon } from "lucide-react"; +import Link from "next/link"; +import { useScrollHide } from "@/hooks/use-scroll-hide"; +import { navFooterItems, navMainItems } from "@/lib/navigation-data"; + +const primaryNavItems = navMainItems.slice(0, 3); +const remainingMainItems = navMainItems.slice(3); + +export function MobileFooter() { + const isHidden = useScrollHide(); + + return ( +
+
+ {primaryNavItems.map((item) => ( + + + + ))} + + + + + } + /> + + + {remainingMainItems.map((item) => ( + + + {item.title} + + } + /> + ))} + + + + {navFooterItems.map((item) => ( + + + {item.title} + + } + /> + ))} + + + +
+ +
+ ); +} diff --git a/apps/examples/components/mobile-header.tsx b/apps/examples/components/mobile-header.tsx new file mode 100644 index 000000000..be04473db --- /dev/null +++ b/apps/examples/components/mobile-header.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { cn } from "@coss/ui/lib/utils"; +import { HeaderActions } from "@/components/header-actions"; +import { Logo } from "@/components/logo"; +import { useScrollHide } from "@/hooks/use-scroll-hide"; + +export function MobileHeader() { + const isHidden = useScrollHide(); + + return ( +
+ + +
+ ); +} diff --git a/apps/examples/components/nav-main.tsx b/apps/examples/components/nav-main.tsx new file mode 100644 index 000000000..6b649684a --- /dev/null +++ b/apps/examples/components/nav-main.tsx @@ -0,0 +1,209 @@ +"use client"; + +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@coss/ui/components/collapsible"; +import { + Menu, + MenuGroup, + MenuGroupLabel, + MenuItem, + MenuPopup, + MenuTrigger, +} from "@coss/ui/components/menu"; +import { TooltipTrigger } from "@coss/ui/components/tooltip"; +import { ChevronRightIcon, type LucideIcon } from "lucide-react"; +import Link from "next/link"; +import { useEffect, useRef } from "react"; +import { + SidebarGroup, + SidebarMenu, + SidebarMenuBadge, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSub, + SidebarMenuSubButton, + SidebarMenuSubItem, + sidebarTooltipHandle, + useSidebarMenuOpen, +} from "@/components/ui/sidebar"; +import { useIsBetweenMdAndLg } from "@/hooks/use-mobile"; + +type BaseNavItem = { + title: string; + url: string; + icon: LucideIcon; + isActive?: boolean; + badge?: string; +}; + +type NavSubItem = { + title: string; + url: string; +}; + +type NavItemWithChildren = BaseNavItem & { items: NavSubItem[] }; +type NavItemLeaf = BaseNavItem & { items?: undefined }; +type NavItem = NavItemLeaf | NavItemWithChildren; + +function hasSubItems(item: NavItem): item is NavItemWithChildren { + return Array.isArray(item.items) && item.items.length > 0; +} + +function NavItemWithSubmenu({ item }: { item: NavItemWithChildren }) { + const isBetweenMdAndLg = useIsBetweenMdAndLg(); + const { registerMenu } = useSidebarMenuOpen(); + const unregisterRef = useRef<(() => void) | null>(null); + + useEffect(() => { + return () => { + if (unregisterRef.current) { + unregisterRef.current(); + } + }; + }, []); + + const TooltipContent = () => item.title; + + return ( + + {/* Menu version for collapsed sidebar (md-lg breakpoint) */} + { + if (open) { + unregisterRef.current = registerMenu(); + } else { + if (unregisterRef.current) { + unregisterRef.current(); + unregisterRef.current = null; + } + } + }} + > +
+ + + {item.badge && ( + + {item.badge} + + )} + + } + /> + } + /> +
+ + + {item.title} + {item.items.map((subItem) => ( + + {subItem.title} + + } + /> + ))} + + +
+ + {/* Collapsible version for expanded sidebar */} + } + > + + } + > + + + {item.title} + + {item.badge && ( + + {item.badge} + + )} + + + + + {item.items.map((subItem) => ( + + + {subItem.title} + + } + /> + + ))} + + + +
+ ); +} + +function NavItemSimple({ item }: { item: NavItemLeaf }) { + const isBetweenMdAndLg = useIsBetweenMdAndLg(); + + return ( + + + + {item.title} + {item.badge && ( + + {item.badge} + + )} + + } + tooltip={isBetweenMdAndLg ? item.title : undefined} + /> + + ); +} + +export function NavMain({ items }: { items: NavItem[] }) { + return ( + + + {items.map((item) => + hasSubItems(item) ? ( + + ) : ( + + ), + )} + + + ); +} diff --git a/apps/examples/components/nav-secondary.tsx b/apps/examples/components/nav-secondary.tsx new file mode 100644 index 000000000..41d29e3e4 --- /dev/null +++ b/apps/examples/components/nav-secondary.tsx @@ -0,0 +1,48 @@ +"use client"; + +import type { LucideIcon } from "lucide-react"; +import type * as React from "react"; + +import { + SidebarGroup, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar"; +import { useIsBetweenMdAndLg } from "@/hooks/use-mobile"; + +export function NavSecondary({ + items, + ...props +}: { + items: { + title: string; + url: string; + icon: LucideIcon; + }[]; +} & React.ComponentPropsWithoutRef) { + const isBetweenMdAndLg = useIsBetweenMdAndLg(); + + return ( + + + {items.map((item) => ( + + + + + {item.title} + + + } + tooltip={isBetweenMdAndLg ? item.title : undefined} + /> + + ))} + + + ); +} diff --git a/apps/examples/components/ui/sidebar.tsx b/apps/examples/components/ui/sidebar.tsx new file mode 100644 index 000000000..fa9871d45 --- /dev/null +++ b/apps/examples/components/ui/sidebar.tsx @@ -0,0 +1,475 @@ +"use client"; + +import { mergeProps } from "@base-ui-components/react/merge-props"; +import { useRender } from "@base-ui-components/react/use-render"; +import { ScrollArea } from "@coss/ui/components/scroll-area"; +import { Separator } from "@coss/ui/components/separator"; +import { Skeleton } from "@coss/ui/components/skeleton"; +import { + Tooltip, + TooltipCreateHandle, + TooltipPopup, + TooltipProvider, + TooltipTrigger, +} from "@coss/ui/components/tooltip"; +import { cn } from "@coss/ui/lib/utils"; +import * as React from "react"; +import { useIsBetweenMdAndLg, useIsMobile } from "@/hooks/use-mobile"; + +type SidebarTooltipHandle = ReturnType< + typeof TooltipCreateHandle +>; + +export const sidebarTooltipHandle: SidebarTooltipHandle = + TooltipCreateHandle(); + +const SidebarMenuOpenContext = React.createContext<{ + openMenuCount: number; + registerMenu: () => () => void; +}>({ + openMenuCount: 0, + registerMenu: () => () => {}, +}); + +function SidebarProvider({ + className, + style, + children, + ...props +}: React.ComponentProps<"div">) { + const [openMenuCount, setOpenMenuCount] = React.useState(0); + + const registerMenu = React.useCallback(() => { + setOpenMenuCount((prev) => prev + 1); + return () => { + setOpenMenuCount((prev) => Math.max(0, prev - 1)); + }; + }, []); + + return ( + + +
+ {children} + + {({ payload: Payload }) => ( + 0 ? "hidden" : undefined} + side="right" + > + {Payload !== undefined && } + + )} + +
+
+
+ ); +} + +export function useSidebarMenuOpen() { + return React.useContext(SidebarMenuOpenContext); +} + +function Sidebar({ + className, + children, + ...props +}: React.ComponentProps<"div">) { + return ( +
+
+
+ {children} +
+
+
+ ); +} + +function SidebarInset({ + className, + children, + ...props +}: React.ComponentProps<"main">) { + return ( +
+
{children}
+
+ ); +} + +function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function SidebarSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function SidebarContent({ className, ...props }: React.ComponentProps<"div">) { + return ( + +
+ + ); +} + +function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function SidebarGroupLabel({ + className, + render, + ...props +}: useRender.ComponentProps<"div">) { + const defaultProps = { + className: cn( + "flex h-8 shrink-0 items-center rounded-lg px-2 font-medium text-sidebar-foreground/70 text-xs outline-hidden ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", + "md:max-lg:-mt-8 md:max-lg:opacity-0", + className, + ), + "data-sidebar": "group-label", + "data-slot": "sidebar-group-label", + }; + + return useRender({ + defaultTagName: "div", + props: mergeProps(defaultProps, props), + render, + }); +} + +function SidebarGroupAction({ + className, + render, + ...props +}: useRender.ComponentProps<"button">) { + const defaultProps = { + className: cn( + "absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-lg p-0 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0", + "after:-inset-2 after:absolute md:after:hidden", + "md:max-lg:hidden", + className, + ), + "data-sidebar": "group-action", + "data-slot": "sidebar-group-action", + }; + + return useRender({ + defaultTagName: "button", + props: mergeProps(defaultProps, props), + render, + }); +} + +function SidebarGroupContent({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) { + return ( +
    + ); +} + +function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) { + return ( +
  • + ); +} + +function SidebarMenuButton({ + isActive = false, + tooltip, + className, + render, + ...props +}: useRender.ComponentProps<"button"> & { + isActive?: boolean; + tooltip?: string | React.ComponentType; +}) { + const isMobile = useIsMobile(); + const isBetweenMdAndLg = useIsBetweenMdAndLg(); + const state = isBetweenMdAndLg ? "collapsed" : "expanded"; + const showTooltip = state === "collapsed" && !isMobile; + + const defaultProps = { + className: cn( + "peer/menu-button cursor-pointer flex w-full items-center gap-2 overflow-hidden rounded-lg p-2 text-left text-sm outline-hidden ring-sidebar-ring 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 group-has-data-[sidebar=menu-action]/menu-item:pe-8 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 data-pressed:bg-sidebar-accent data-pressed:text-sidebar-accent-foreground max-lg:size-10 max-lg:p-0 max-lg:justify-center h-8 [&>span:last-child]:truncate md:[&>svg:not([class*='size-'])]:size-4 [&>svg:not([class*='size-'])]:size-5 [&>svg]:shrink-0", + className, + ), + "data-active": isActive, + "data-sidebar": "menu-button", + "data-slot": "sidebar-menu-button", + }; + + const buttonProps = mergeProps<"button">(defaultProps, props); + + const buttonElement = useRender({ + defaultTagName: "button", + props: buttonProps, + render, + }); + + if (!tooltip || !showTooltip) { + return buttonElement; + } + + // Convert string tooltip to a component + const TooltipContent = typeof tooltip === "string" ? () => tooltip : tooltip; + + return ( + >} + /> + ); +} + +function SidebarMenuAction({ + className, + showOnHover = false, + render, + ...props +}: useRender.ComponentProps<"button"> & { + showOnHover?: boolean; +}) { + const defaultProps = { + className: cn( + "absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-lg p-0 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0", + "after:-inset-2 after:absolute md:after:hidden", + "md:max-lg:hidden", + showOnHover && + "group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0", + className, + ), + "data-sidebar": "menu-action", + "data-slot": "sidebar-menu-action", + }; + + return useRender({ + defaultTagName: "button", + props: mergeProps<"button">(defaultProps, props), + render, + }); +} + +function SidebarMenuBadge({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
    + ); +} + +function SidebarMenuSkeleton({ + className, + showIcon = false, + ...props +}: React.ComponentProps<"div"> & { + showIcon?: boolean; +}) { + // Random width between 50 to 90%. + const width = React.useMemo(() => { + return `${Math.floor(Math.random() * 40) + 50}%`; + }, []); + + return ( +
    + {showIcon && ( + + )} + +
    + ); +} + +function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) { + return ( +
      + ); +} + +function SidebarMenuSubItem({ + className, + ...props +}: React.ComponentProps<"li">) { + return ( +
    • + ); +} + +function SidebarMenuSubButton({ + isActive = false, + className, + render, + ...props +}: useRender.ComponentProps<"a"> & { + isActive?: boolean; +}) { + const defaultProps = { + className: cn( + "-translate-x-px flex h-7 min-w-0 items-center gap-2 overflow-hidden rounded-lg px-2 text-sm text-sidebar-foreground outline-hidden ring-sidebar-ring 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 [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground", + "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground", + "md:max-lg:hidden", + className, + ), + "data-active": isActive, + "data-sidebar": "menu-sub-button", + "data-slot": "sidebar-menu-sub-button", + }; + + return useRender({ + defaultTagName: "a", + props: mergeProps<"a">(defaultProps, props), + render, + }); +} + +export { + Sidebar, + SidebarContent, + SidebarGroup, + SidebarGroupAction, + SidebarGroupContent, + SidebarGroupLabel, + SidebarHeader, + SidebarInset, + SidebarMenu, + SidebarMenuAction, + SidebarMenuBadge, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSkeleton, + SidebarMenuSub, + SidebarMenuSubButton, + SidebarMenuSubItem, + SidebarProvider, + SidebarSeparator, +}; diff --git a/apps/examples/components/user-menu.tsx b/apps/examples/components/user-menu.tsx new file mode 100644 index 000000000..c1ba29213 --- /dev/null +++ b/apps/examples/components/user-menu.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { + Avatar, + AvatarFallback, + AvatarImage, +} from "@coss/ui/components/avatar"; +import { + Menu, + MenuGroup, + MenuGroupLabel, + MenuItem, + MenuPopup, + MenuSeparator, + MenuTrigger, +} from "@coss/ui/components/menu"; +import { + GaugeIcon, + LogOutIcon, + MessageCircleQuestionMarkIcon, + MilestoneIcon, + MonitorDownIcon, + MoonStarIcon, + SettingsIcon, + UserRoundIcon, +} from "lucide-react"; +import { SidebarMenuButton } from "@/components/ui/sidebar"; +import { useIsBetweenMdAndLg } from "@/hooks/use-mobile"; + +interface UserMenuProps { + variant?: "sidebar" | "mobile"; +} + +export function UserMenu({ variant = "sidebar" }: UserMenuProps) { + const isBetweenMdAndLg = useIsBetweenMdAndLg(); + + return ( + + + } + > + + + LT + + + User menu + + + + Luke Tracy + + + My profile + + + + My settings + + + + Out of office + + + + + + + Roadmap + + + + Help + + + + Download desktop app + + + + Platform + + + + + + Sign out + + + + ); +} diff --git a/apps/examples/hooks/use-mobile.ts b/apps/examples/hooks/use-mobile.ts new file mode 100644 index 000000000..afb93eb08 --- /dev/null +++ b/apps/examples/hooks/use-mobile.ts @@ -0,0 +1,48 @@ +import * as React from "react"; + +const MOBILE_BREAKPOINT = 768; // Tailwind md breakpoint +const LG_BREAKPOINT = 1024; // Tailwind lg breakpoint + +export function useIsMobile() { + const [isMobile, setIsMobile] = React.useState( + undefined, + ); + + React.useEffect(() => { + 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); + return () => mql.removeEventListener("change", onChange); + }, []); + + return !!isMobile; +} + +export function useIsBetweenMdAndLg() { + const [isBetween, setIsBetween] = React.useState( + undefined, + ); + + React.useEffect(() => { + const mql = window.matchMedia( + `(min-width: ${MOBILE_BREAKPOINT}px) and (max-width: ${LG_BREAKPOINT - 1}px)`, + ); + const onChange = () => { + setIsBetween( + window.innerWidth >= MOBILE_BREAKPOINT && + window.innerWidth < LG_BREAKPOINT, + ); + }; + mql.addEventListener("change", onChange); + setIsBetween( + window.innerWidth >= MOBILE_BREAKPOINT && + window.innerWidth < LG_BREAKPOINT, + ); + return () => mql.removeEventListener("change", onChange); + }, []); + + return !!isBetween; +} diff --git a/apps/examples/hooks/use-scroll-hide.ts b/apps/examples/hooks/use-scroll-hide.ts new file mode 100644 index 000000000..d04b27130 --- /dev/null +++ b/apps/examples/hooks/use-scroll-hide.ts @@ -0,0 +1,38 @@ +import * as React from "react"; + +const DEFAULT_SCROLL_THRESHOLD = 48; + +export function useScrollHide(threshold = DEFAULT_SCROLL_THRESHOLD) { + const [isHidden, setIsHidden] = React.useState(false); + const lastScrollY = React.useRef(0); + + React.useEffect(() => { + const handleScroll = () => { + const currentY = window.scrollY; + const delta = currentY - lastScrollY.current; + + if (currentY <= 0) { + setIsHidden(false); + lastScrollY.current = currentY; + return; + } + + if (Math.abs(delta) < threshold) { + return; + } + + if (delta > 0) { + setIsHidden(true); + } else { + setIsHidden(false); + } + + lastScrollY.current = currentY; + }; + + window.addEventListener("scroll", handleScroll, { passive: true }); + return () => window.removeEventListener("scroll", handleScroll); + }, [threshold]); + + return isHidden; +} diff --git a/apps/examples/lib/navigation-data.ts b/apps/examples/lib/navigation-data.ts new file mode 100644 index 000000000..83903e362 --- /dev/null +++ b/apps/examples/lib/navigation-data.ts @@ -0,0 +1,145 @@ +import { + ActivityIcon, + CalendarIcon, + ClockFadingIcon, + ContactRoundIcon, + CopyIcon, + ExternalLinkIcon, + GiftIcon, + Grid2x2Plus, + Link2Icon, + type LucideIcon, + RouteIcon, + SettingsIcon, + UsersRoundIcon, + WorkflowIcon, +} from "lucide-react"; + +export interface NavItem { + title: string; + url: string; + icon: LucideIcon; + isActive?: boolean; + badge?: string; + items?: { + title: string; + url: string; + }[]; +} + +export interface User { + avatar: string; + email: string; + name: string; +} + +export const navMainItems: NavItem[] = [ + { + icon: Link2Icon, + isActive: true, + title: "Event Types", + url: "#", + }, + { + icon: CalendarIcon, + items: [ + { + title: "Upcoming", + url: "#", + }, + { + title: "Unconfirmed", + url: "#", + }, + { + title: "Recurring", + url: "#", + }, + { + title: "Past", + url: "#", + }, + { + title: "Canceled", + url: "#", + }, + ], + title: "Bookings", + url: "#", + }, + { + icon: ClockFadingIcon, + title: "Availability", + url: "#", + }, + { + icon: ContactRoundIcon, + title: "Members", + url: "#", + }, + { + icon: UsersRoundIcon, + title: "Teams", + url: "#", + }, + { + icon: Grid2x2Plus, + items: [ + { + title: "App Store", + url: "#", + }, + { + title: "Installed Apps", + url: "#", + }, + ], + title: "Apps", + url: "#", + }, + { + icon: RouteIcon, + title: "Routing", + url: "#", + }, + { + badge: "Cal.ai", + icon: WorkflowIcon, + title: "Workflows", + url: "#", + }, + { + icon: ActivityIcon, + title: "Insights", + url: "#", + }, +]; + +export const navFooterItems: NavItem[] = [ + { + icon: ExternalLinkIcon, + title: "View public page", + url: "#", + }, + { + icon: CopyIcon, + title: "Copy public page link", + url: "#", + }, + { + icon: GiftIcon, + title: "Refer and earn", + url: "#", + }, + { + icon: SettingsIcon, + title: "Settings", + url: "#", + }, +]; + +export const user: User = { + avatar: "", + email: "pasqua@example.com", + name: "Pasquale", +}; diff --git a/apps/examples/next.config.ts b/apps/examples/next.config.ts new file mode 100644 index 000000000..5dec87516 --- /dev/null +++ b/apps/examples/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + transpilePackages: ["@coss/ui"], +}; + +export default nextConfig; diff --git a/apps/examples/package.json b/apps/examples/package.json new file mode 100644 index 000000000..555bd5857 --- /dev/null +++ b/apps/examples/package.json @@ -0,0 +1,33 @@ +{ + "dependencies": { + "@base-ui-components/react": "^1.0.0-rc.0", + "@coss/ui": "workspace:*", + "@hugeicons/core-free-icons": "^2.0.0", + "@hugeicons/react": "^1.1.1", + "lucide-react": "^0.555.0", + "next": "16.0.9", + "react": "19.2.3", + "react-dom": "19.2.3" + }, + "devDependencies": { + "@coss/typescript-config": "workspace:*", + "@tailwindcss/postcss": "^4.1.17", + "@types/node": "^24.10.1", + "@types/react": "19.2.6", + "@types/react-dom": "19.2.3", + "tailwindcss": "^4.1.17", + "typescript": "^5.9.3" + }, + "license": "AGPL-3.0-or-later", + "name": "examples", + "private": true, + "scripts": { + "build": "next build", + "clean": "rm -rf node_modules && rm -rf .turbo && rm -rf .next", + "dev": "next dev", + "lint": "biome lint .", + "start": "next start", + "typecheck": "tsc --noEmit" + }, + "version": "0.1.0" +} diff --git a/apps/examples/postcss.config.mjs b/apps/examples/postcss.config.mjs new file mode 100644 index 000000000..af6087cdf --- /dev/null +++ b/apps/examples/postcss.config.mjs @@ -0,0 +1,3 @@ +import { postcssConfig } from "@coss/ui/postcss.config"; + +export default postcssConfig; diff --git a/apps/examples/tsconfig.json b/apps/examples/tsconfig.json new file mode 100644 index 000000000..e84f6335c --- /dev/null +++ b/apps/examples/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "paths": { + "@/*": ["./*"] + }, + "plugins": [ + { + "name": "next" + } + ] + }, + "exclude": ["node_modules"], + "extends": "@coss/typescript-config/nextjs.json", + "include": [ + "**/*.ts", + "**/*.tsx", + "next-env.d.ts", + "next.config.ts", + ".next/types/**/*.ts" + ] +} diff --git a/bun.lock b/bun.lock index 4f15dd371..5eadee9a2 100644 --- a/bun.lock +++ b/bun.lock @@ -11,6 +11,29 @@ "typescript": "^5.9.3", }, }, + "apps/examples": { + "name": "examples", + "version": "0.1.0", + "dependencies": { + "@base-ui-components/react": "^1.0.0-rc.0", + "@coss/ui": "workspace:*", + "@hugeicons/core-free-icons": "^2.0.0", + "@hugeicons/react": "^1.1.1", + "lucide-react": "^0.555.0", + "next": "16.0.9", + "react": "19.2.3", + "react-dom": "19.2.3", + }, + "devDependencies": { + "@coss/typescript-config": "workspace:*", + "@tailwindcss/postcss": "^4.1.17", + "@types/node": "^24.10.1", + "@types/react": "19.2.6", + "@types/react-dom": "19.2.3", + "tailwindcss": "^4.1.17", + "typescript": "^5.9.3", + }, + }, "apps/origin": { "name": "origin", "version": "0.1.0", @@ -236,6 +259,10 @@ "@babel/types": ["@babel/types@7.28.4", "", { "dependencies": { "@babel/helper-string-parser": "7.27.1", "@babel/helper-validator-identifier": "7.27.1" } }, "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q=="], + "@base-ui-components/react": ["@base-ui-components/react@1.0.0-rc.0", "", { "dependencies": { "@babel/runtime": "^7.28.4", "@base-ui-components/utils": "0.2.2", "@floating-ui/react-dom": "^2.1.6", "@floating-ui/utils": "^0.2.10", "reselect": "^5.1.1", "tabbable": "^6.3.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-9lhUFbJcbXvc9KulLev1WTFxS/alJRBWDH/ibKSQaNvmDwMFS2gKp1sTeeldYSfKuS/KC1w2MZutc0wHu2hRHQ=="], + + "@base-ui-components/utils": ["@base-ui-components/utils@0.2.2", "", { "dependencies": { "@babel/runtime": "^7.28.4", "@floating-ui/utils": "^0.2.10", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-rNJCD6TFy3OSRDKVHJDzLpxO3esTV1/drRtWNUpe7rCpPN9HZVHUCuP+6rdDYDGWfXnQHbqi05xOyRP2iZAlkw=="], + "@base-ui/react": ["@base-ui/react@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.28.4", "@base-ui/utils": "0.2.3", "@floating-ui/react-dom": "^2.1.6", "@floating-ui/utils": "^0.2.10", "reselect": "^5.1.1", "tabbable": "^6.3.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-4USBWz++DUSLTuIYpbYkSgy1F9ZmNG9S/lXvlUN6qMK0P0RlW+6eQmDUB4DgZ7HVvtXl4pvi4z5J2fv6Z3+9hg=="], "@base-ui/utils": ["@base-ui/utils@0.2.3", "", { "dependencies": { "@babel/runtime": "^7.28.4", "@floating-ui/utils": "^0.2.10", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-/CguQ2PDaOzeVOkllQR8nocJ0FFIDqsWIcURsVmm53QGo8NhFNpePjNlyPIB41luxfOqnG7PU0xicMEw3ls7XQ=="], @@ -1236,6 +1263,8 @@ "eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], + "examples": ["examples@workspace:apps/examples"], + "execa": ["execa@7.2.0", "", { "dependencies": { "cross-spawn": "7.0.6", "get-stream": "6.0.1", "human-signals": "4.3.1", "is-stream": "3.0.0", "merge-stream": "2.0.0", "npm-run-path": "5.3.0", "onetime": "6.0.0", "signal-exit": "3.0.7", "strip-final-newline": "3.0.0" } }, "sha512-UduyVP7TLB5IcAQl+OzLyLcS/l32W/GLg+AhHJ+ow40FOk2U3SAllPwR44v4vmdFwIWqpdwxxpQbF1n5ta9seA=="], "express": ["express@5.1.0", "", { "dependencies": { "accepts": "2.0.0", "body-parser": "2.2.0", "content-disposition": "1.0.0", "content-type": "1.0.5", "cookie": "0.7.2", "cookie-signature": "1.2.2", "debug": "4.4.1", "encodeurl": "2.0.0", "escape-html": "1.0.3", "etag": "1.8.1", "finalhandler": "2.1.0", "fresh": "2.0.0", "http-errors": "2.0.0", "merge-descriptors": "2.0.0", "mime-types": "3.0.1", "on-finished": "2.4.1", "once": "1.4.0", "parseurl": "1.3.3", "proxy-addr": "2.0.7", "qs": "6.14.0", "range-parser": "1.2.1", "router": "2.2.0", "send": "1.2.0", "serve-static": "2.2.0", "statuses": "2.0.2", "type-is": "2.0.1", "vary": "1.1.2" } }, "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA=="], diff --git a/packages/ui/package.json b/packages/ui/package.json index 52ac8bfb7..80f53d68d 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -26,6 +26,7 @@ }, "exports": { "./components/*": "./src/components/*.tsx", + "./fonts": "./src/fonts/index.ts", "./globals.css": "./src/styles/globals.css", "./hooks/*": "./src/hooks/*.ts", "./lib/*": "./src/lib/*.ts", diff --git a/packages/ui/src/fonts/CalSans-Regular.woff2 b/packages/ui/src/fonts/CalSans-Regular.woff2 new file mode 100644 index 000000000..6c83a2df3 Binary files /dev/null and b/packages/ui/src/fonts/CalSans-Regular.woff2 differ diff --git a/packages/ui/src/fonts/CalSansUI[MODE,wght].woff2 b/packages/ui/src/fonts/CalSansUI[MODE,wght].woff2 new file mode 100644 index 000000000..d353c5893 Binary files /dev/null and b/packages/ui/src/fonts/CalSansUI[MODE,wght].woff2 differ diff --git a/packages/ui/src/fonts/README.md b/packages/ui/src/fonts/README.md new file mode 100644 index 000000000..6e03ca1bc --- /dev/null +++ b/packages/ui/src/fonts/README.md @@ -0,0 +1,49 @@ +# Shared Fonts + +This directory contains shared font files and configurations used across all apps in the monorepo. + +## Usage + +Import fonts directly from the shared UI package: + +```tsx +import { fontSans, fontHeading } from "@coss/ui/fonts"; + +export default function RootLayout({ children }) { + return ( + + + {children} + + + ); +} +``` + +## Available Fonts + +- `fontSans` - Cal Sans UI variable font (supports multiple weights and modes) +- `fontHeading` - Cal Sans Regular font + +## Adding New Fonts + +1. Place the font file in this directory (`packages/ui/src/fonts/`) +2. Add a new font configuration in `index.ts`: + +```typescript +export const yourNewFont = localFont({ + display: "swap", + src: "./YourFont.woff2", + variable: "--font-your-name", +}); +``` + +3. Use it in any app by importing from `@coss/ui/fonts` + +## Benefits of This Approach + +- Single source of truth for fonts +- No fragile relative paths +- Type-safe imports +- Versioned with the UI package +- Easy to update across all apps diff --git a/packages/ui/src/fonts/index.ts b/packages/ui/src/fonts/index.ts new file mode 100644 index 000000000..06ff61512 --- /dev/null +++ b/packages/ui/src/fonts/index.ts @@ -0,0 +1,13 @@ +import localFont from "next/font/local"; + +export const fontSans = localFont({ + display: "swap", + src: "./CalSansUI[MODE,wght].woff2", + variable: "--font-sans", +}); + +export const fontHeading = localFont({ + display: "swap", + src: "./CalSans-Regular.woff2", + variable: "--font-heading", +});