@nikala-ui/core 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/package.json +43 -2
  2. package/registry/api-table.json +21 -0
  3. package/registry/banner.json +1 -1
  4. package/registry/button.json +1 -1
  5. package/registry/callout.json +19 -0
  6. package/registry/checkbox.json +4 -1
  7. package/registry/code-block.json +26 -0
  8. package/registry/code-group.json +20 -0
  9. package/registry/combobox.json +1 -1
  10. package/registry/command.json +3 -2
  11. package/registry/component-viewer.json +25 -0
  12. package/registry/container.json +18 -0
  13. package/registry/context-menu.json +1 -1
  14. package/registry/create-app-updater.json +13 -0
  15. package/registry/create-document-tabs.json +13 -0
  16. package/registry/create-global-shortcut.json +13 -0
  17. package/registry/create-tauri-window.json +13 -0
  18. package/registry/create-tiptap-editor.json +34 -0
  19. package/registry/dialog.json +1 -1
  20. package/registry/dropdown-menu.json +3 -2
  21. package/registry/field.json +1 -1
  22. package/registry/file-tree.json +21 -0
  23. package/registry/footer.json +1 -1
  24. package/registry/form-message.json +3 -2
  25. package/registry/icon-button.json +1 -1
  26. package/registry/index.json +336 -5
  27. package/registry/navbar.json +1 -1
  28. package/registry/navigation-menu.json +1 -1
  29. package/registry/number-input.json +1 -1
  30. package/registry/package-manager-tabs.json +24 -0
  31. package/registry/pager.json +21 -0
  32. package/registry/popover.json +4 -1
  33. package/registry/resizable.json +1 -1
  34. package/registry/rich-text-editor.json +95 -0
  35. package/registry/scroll-area.json +1 -1
  36. package/registry/section-heading.json +21 -0
  37. package/registry/select.json +3 -2
  38. package/registry/sheet.json +1 -1
  39. package/registry/sidebar.json +1 -1
  40. package/registry/status.json +1 -1
  41. package/registry/steps.json +20 -0
  42. package/registry/switch.json +4 -1
  43. package/registry/table-of-contents.json +23 -0
  44. package/registry/tabs.json +4 -1
  45. package/registry/theme-manager.json +6 -5
  46. package/registry/titlebar-tabs.json +24 -0
  47. package/registry/titlebar.json +26 -0
  48. package/registry/toggle-group.json +1 -1
  49. package/registry/updater-modal.json +26 -0
  50. package/src/index.ts +48 -0
  51. package/src/registry/components/ui/api-table.tsx +117 -0
  52. package/src/registry/components/ui/banner.tsx +0 -2
  53. package/src/registry/components/ui/button.tsx +1 -1
  54. package/src/registry/components/ui/callout.tsx +137 -0
  55. package/src/registry/components/ui/checkbox.tsx +1 -1
  56. package/src/registry/components/ui/code-block.tsx +326 -0
  57. package/src/registry/components/ui/code-group.tsx +105 -0
  58. package/src/registry/components/ui/combobox.tsx +83 -18
  59. package/src/registry/components/ui/command.tsx +110 -106
  60. package/src/registry/components/ui/component-viewer.tsx +363 -0
  61. package/src/registry/components/ui/container.tsx +45 -0
  62. package/src/registry/components/ui/context-menu.tsx +67 -22
  63. package/src/registry/components/ui/dialog.tsx +42 -32
  64. package/src/registry/components/ui/dropdown-menu.tsx +69 -31
  65. package/src/registry/components/ui/field.tsx +7 -3
  66. package/src/registry/components/ui/file-tree.tsx +181 -0
  67. package/src/registry/components/ui/footer.tsx +1 -1
  68. package/src/registry/components/ui/form-message.tsx +2 -2
  69. package/src/registry/components/ui/icon-button.tsx +1 -1
  70. package/src/registry/components/ui/navbar.tsx +18 -11
  71. package/src/registry/components/ui/navigation-menu.tsx +2 -2
  72. package/src/registry/components/ui/number-input.tsx +3 -3
  73. package/src/registry/components/ui/package-manager-tabs.tsx +226 -0
  74. package/src/registry/components/ui/pager.tsx +102 -0
  75. package/src/registry/components/ui/popover.tsx +1 -1
  76. package/src/registry/components/ui/resizable.tsx +3 -1
  77. package/src/registry/components/ui/rich-text-editor/bubble-menu.tsx +236 -0
  78. package/src/registry/components/ui/rich-text-editor/editor.tsx +308 -0
  79. package/src/registry/components/ui/rich-text-editor/extensions.ts +116 -0
  80. package/src/registry/components/ui/rich-text-editor/footer.tsx +36 -0
  81. package/src/registry/components/ui/rich-text-editor/image-dialog.tsx +75 -0
  82. package/src/registry/components/ui/rich-text-editor/index.ts +11 -0
  83. package/src/registry/components/ui/rich-text-editor/link-dialog.tsx +48 -0
  84. package/src/registry/components/ui/rich-text-editor/markdown.ts +170 -0
  85. package/src/registry/components/ui/rich-text-editor/slash-command.tsx +301 -0
  86. package/src/registry/components/ui/rich-text-editor/table-controls.tsx +68 -0
  87. package/src/registry/components/ui/rich-text-editor/toolbar.tsx +465 -0
  88. package/src/registry/components/ui/rich-text-editor/use-editor.ts +231 -0
  89. package/src/registry/components/ui/scroll-area.tsx +12 -2
  90. package/src/registry/components/ui/section-heading.tsx +108 -0
  91. package/src/registry/components/ui/select.tsx +16 -19
  92. package/src/registry/components/ui/sheet.tsx +58 -53
  93. package/src/registry/components/ui/sidebar.tsx +4 -4
  94. package/src/registry/components/ui/status.tsx +7 -5
  95. package/src/registry/components/ui/steps.tsx +198 -0
  96. package/src/registry/components/ui/switch.tsx +1 -1
  97. package/src/registry/components/ui/table-of-contents.tsx +131 -0
  98. package/src/registry/components/ui/tabs.tsx +1 -1
  99. package/src/registry/components/ui/theme-toggle.tsx +175 -139
  100. package/src/registry/components/ui/titlebar-tabs.tsx +773 -0
  101. package/src/registry/components/ui/titlebar.tsx +468 -0
  102. package/src/registry/components/ui/toggle-group.tsx +1 -1
  103. package/src/registry/components/ui/updater-modal.tsx +254 -0
  104. package/src/registry/metadata.ts +167 -6
  105. package/src/registry/providers/theme-provider.tsx +16 -8
  106. package/src/registry/providers/theme-script.tsx +29 -9
  107. package/src/registry/providers/theme-transitions.ts +81 -50
@@ -17,7 +17,7 @@
17
17
  "files": [
18
18
  {
19
19
  "path": "ui/sidebar.tsx",
20
- "content": "import {\n createContext,\n createSignal,\n createMemo,\n createEffect,\n useContext,\n splitProps,\n onMount,\n onCleanup,\n type Component,\n type JSX,\n type ParentComponent,\n type Accessor,\n Show,\n} from \"solid-js\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport { PanelLeft, PanelRight } from \"lucide-solid\";\nimport { Tooltip, TooltipTrigger, TooltipContent } from \"./tooltip\";\nimport { Skeleton } from \"./skeleton\";\nimport { Separator } from \"./separator\";\nimport { cn } from \"@/lib/cn\";\n\n/* --- Constants --- */\nconst SIDEBAR_COOKIE_NAME = \"nikala_sidebar_state\";\nconst SIDEBAR_WIDTH = \"17rem\";\nconst SIDEBAR_WIDTH_ICON = \"3.5rem\";\nconst SIDEBAR_KEYBOARD_SHORTCUT = \"b\";\n\n/* --- 1. Context & Types --- */\nexport type SidebarState = \"expanded\" | \"collapsed\";\nexport type SidebarSide = \"left\" | \"right\";\n\nexport interface SidebarContextValue {\n state: Accessor<SidebarState>;\n open: Accessor<boolean>;\n setOpen: (open: boolean) => void;\n openMobile: Accessor<boolean>;\n setOpenMobile: (open: boolean) => void;\n isMobile: Accessor<boolean>;\n toggleSidebar: () => void;\n side: Accessor<SidebarSide>;\n setSide: (side: SidebarSide) => void;\n}\n\nconst SidebarContext = createContext<SidebarContextValue>();\n\nconst fallbackSidebarContext: SidebarContextValue = {\n state: () => \"expanded\",\n open: () => true,\n setOpen: () => {},\n openMobile: () => false,\n setOpenMobile: () => {},\n isMobile: () => false,\n toggleSidebar: () => {},\n side: () => \"left\",\n setSide: () => {},\n};\n\nexport function useSidebar() {\n const context = useContext(SidebarContext);\n return context ?? fallbackSidebarContext;\n}\n\n/* --- 2. SidebarProvider --- */\nexport interface SidebarProviderProps extends JSX.HTMLAttributes<HTMLDivElement> {\n defaultOpen?: boolean;\n open?: Accessor<boolean>;\n onOpenChange?: (open: boolean) => void;\n side?: SidebarSide;\n class?: string;\n style?: JSX.CSSProperties;\n}\n\nexport const SidebarProvider: ParentComponent<SidebarProviderProps> = (props) => {\n const [local, rest] = splitProps(props, [\n \"defaultOpen\",\n \"open\",\n \"onOpenChange\",\n \"side\",\n \"class\",\n \"style\",\n \"children\",\n ]);\n\n const [internalOpen, setInternalOpen] = createSignal<boolean>(local.defaultOpen ?? true);\n const [openMobile, setOpenMobile] = createSignal<boolean>(false);\n const [isMobile, setIsMobile] = createSignal<boolean>(false);\n const [side, setSide] = createSignal<SidebarSide>(local.side ?? \"left\");\n\n createEffect(() => {\n if (local.side) {\n setSide(local.side);\n }\n });\n\n const open = () => (local.open !== undefined ? local.open() : internalOpen());\n\n const setOpen = (value: boolean) => {\n if (local.open === undefined) {\n setInternalOpen(value);\n }\n local.onOpenChange?.(value);\n\n if (typeof document !== \"undefined\") {\n document.cookie = `${SIDEBAR_COOKIE_NAME}=${value}; path=/; max-age=${60 * 60 * 24 * 7}`;\n }\n };\n\n const toggleSidebar = () => {\n setOpen(!open());\n };\n\n const state = createMemo<SidebarState>(() => (open() ? \"expanded\" : \"collapsed\"));\n\n onMount(() => {\n if (typeof window === \"undefined\") return;\n\n const checkMobile = () => {\n setIsMobile(window.innerWidth < 768);\n };\n\n checkMobile();\n window.addEventListener(\"resize\", checkMobile, { passive: true });\n\n // Keyboard shortcut (⌘B / Ctrl+B)\n const handleKeyDown = (e: KeyboardEvent) => {\n if (\n (e.metaKey || e.ctrlKey) &&\n e.key.toLowerCase() === SIDEBAR_KEYBOARD_SHORTCUT &&\n !e.defaultPrevented\n ) {\n e.preventDefault();\n toggleSidebar();\n }\n };\n\n window.addEventListener(\"keydown\", handleKeyDown);\n\n onCleanup(() => {\n window.removeEventListener(\"resize\", checkMobile);\n window.removeEventListener(\"keydown\", handleKeyDown);\n });\n });\n\n const contextValue: SidebarContextValue = {\n state,\n open,\n setOpen,\n openMobile,\n setOpenMobile,\n isMobile,\n toggleSidebar,\n side,\n setSide,\n };\n\n return (\n <SidebarContext.Provider value={contextValue}>\n <div\n style={{\n \"--sidebar-width\": SIDEBAR_WIDTH,\n \"--sidebar-width-icon\": SIDEBAR_WIDTH_ICON,\n ...(typeof local.style === \"object\" ? local.style : {}),\n } as JSX.CSSProperties}\n class={cn(\n \"group/sidebar-wrapper relative flex w-full max-w-full\",\n local.class\n )}\n {...rest}\n >\n {local.children}\n </div>\n </SidebarContext.Provider>\n );\n};\n\n/* --- 3. Sidebar Root --- */\nexport interface SidebarProps extends JSX.HTMLAttributes<HTMLDivElement> {\n side?: SidebarSide;\n variant?: \"sidebar\" | \"floating\" | \"inset\";\n collapsible?: \"offcanvas\" | \"icon\" | \"none\";\n class?: string;\n}\n\nexport const Sidebar: ParentComponent<SidebarProps> = (props) => {\n const [local, rest] = splitProps(props, [\n \"side\",\n \"variant\",\n \"collapsible\",\n \"class\",\n \"children\",\n ]);\n\n const sidebar = useSidebar();\n const side = () => local.side ?? sidebar.side();\n const variant = () => local.variant ?? \"sidebar\";\n const collapsible = () => local.collapsible ?? \"icon\";\n\n createEffect(() => {\n if (local.side) {\n sidebar.setSide(local.side);\n }\n });\n\n const isCollapsed = () => sidebar.state() === \"collapsed\" && collapsible() !== \"none\";\n\n return (\n <div\n data-state={isCollapsed() ? \"collapsed\" : \"expanded\"}\n data-collapsible={isCollapsed() ? collapsible() : \"\"}\n data-variant={variant()}\n data-side={side()}\n class={cn(\n \"group relative flex flex-col bg-card text-card-foreground transition-all duration-200 ease-in-out shrink-0\",\n !isCollapsed()\n ? \"w-[var(--sidebar-width,17rem)]\"\n : collapsible() === \"icon\"\n ? \"w-[var(--sidebar-width-icon,3.5rem)]\"\n : \"w-0 overflow-hidden opacity-0 border-none\",\n // Side borders\n variant() === \"sidebar\" && (side() === \"left\" ? \"border-r border-border\" : \"border-l border-border\"),\n // Floating variant\n variant() === \"floating\" && \"m-2 rounded-lg border border-border shadow-md\",\n // Inset variant\n variant() === \"inset\" && \"m-2 rounded-lg border border-border/80 bg-card shadow-2xs\",\n local.class\n )}\n {...rest}\n >\n <div\n data-sidebar=\"sidebar\"\n class=\"flex h-full w-full flex-col overflow-hidden\"\n >\n {local.children}\n </div>\n </div>\n );\n};\n\n/* --- 4. SidebarTrigger --- */\nexport interface SidebarTriggerProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {\n class?: string;\n}\n\nexport const SidebarTrigger: Component<SidebarTriggerProps> = (props) => {\n const [local, rest] = splitProps(props, [\"class\", \"onClick\"]);\n const { toggleSidebar, side } = useSidebar();\n\n return (\n <button\n type=\"button\"\n aria-label=\"Toggle Sidebar\"\n onClick={(e) => {\n if (typeof local.onClick === \"function\") {\n local.onClick(e);\n }\n toggleSidebar();\n }}\n class={cn(\n \"inline-flex size-7 items-center justify-center rounded-md border border-border/60 bg-background text-foreground hover:bg-muted focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring cursor-pointer transition-colors shadow-2xs\",\n local.class\n )}\n {...rest}\n >\n <Show when={side() === \"right\"} fallback={<PanelLeft class=\"size-4\" />}>\n <PanelRight class=\"size-4\" />\n </Show>\n <span class=\"sr-only\">Toggle Sidebar</span>\n </button>\n );\n};\n\n/* --- 5. SidebarRail --- */\nexport interface SidebarRailProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {\n class?: string;\n}\n\nexport const SidebarRail: Component<SidebarRailProps> = (props) => {\n const [local, rest] = splitProps(props, [\"class\"]);\n const { toggleSidebar } = useSidebar();\n\n return (\n <button\n type=\"button\"\n data-sidebar=\"rail\"\n aria-label=\"Toggle Sidebar Rail\"\n tabIndex={-1}\n onClick={toggleSidebar}\n title=\"Toggle Sidebar\"\n class={cn(\n \"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex cursor-w-resize\",\n local.class\n )}\n {...rest}\n />\n );\n};\n\n/* --- 6. SidebarInset --- */\nexport interface SidebarInsetProps extends JSX.HTMLAttributes<HTMLElement> {\n class?: string;\n}\n\nexport const SidebarInset: ParentComponent<SidebarInsetProps> = (props) => {\n const [local, rest] = splitProps(props, [\"class\", \"children\"]);\n\n return (\n <main\n class={cn(\n \"relative flex min-h-0 flex-1 flex-col bg-background\",\n local.class\n )}\n {...rest}\n >\n {local.children}\n </main>\n );\n};\n\n/* --- 7. Sidebar Structural Sections --- */\nexport interface SidebarSectionProps extends JSX.HTMLAttributes<HTMLDivElement> {\n class?: string;\n}\n\nexport const SidebarHeader: ParentComponent<SidebarSectionProps> = (props) => {\n const [local, rest] = splitProps(props, [\"class\", \"children\"]);\n\n return (\n <div\n data-sidebar=\"header\"\n class={cn(\"flex flex-col gap-2 p-3 border-b border-border/40 shrink-0 group-data-[collapsible=icon]:p-2 group-data-[collapsible=icon]:items-center\", local.class)}\n {...rest}\n >\n {local.children}\n </div>\n );\n};\n\nexport const SidebarFooter: ParentComponent<SidebarSectionProps> = (props) => {\n const [local, rest] = splitProps(props, [\"class\", \"children\"]);\n\n return (\n <div\n data-sidebar=\"footer\"\n class={cn(\"flex flex-col gap-2 p-3 mt-auto border-t border-border/40 shrink-0 group-data-[collapsible=icon]:p-2 group-data-[collapsible=icon]:items-center\", local.class)}\n {...rest}\n >\n {local.children}\n </div>\n );\n};\n\nexport const SidebarSeparator: Component<SidebarSectionProps> = (props) => {\n const [local, rest] = splitProps(props, [\"class\"]);\n\n return (\n <Separator\n data-sidebar=\"separator\"\n class={cn(\"mx-2 w-auto bg-border/50 my-1 group-data-[collapsible=icon]:mx-1\", local.class)}\n {...rest}\n />\n );\n};\n\nexport const SidebarContent: ParentComponent<SidebarSectionProps> = (props) => {\n const [local, rest] = splitProps(props, [\"class\", \"children\"]);\n\n return (\n <div\n data-sidebar=\"content\"\n class={cn(\n \"flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto p-2 group-data-[collapsible=icon]:p-1 group-data-[collapsible=icon]:overflow-hidden group-data-[collapsible=icon]:items-center\",\n local.class\n )}\n {...rest}\n >\n {local.children}\n </div>\n );\n};\n\n/* --- 8. Sidebar Groups --- */\nexport const SidebarGroup: ParentComponent<SidebarSectionProps> = (props) => {\n const [local, rest] = splitProps(props, [\"class\", \"children\"]);\n\n return (\n <div\n data-sidebar=\"group\"\n class={cn(\"relative flex w-full min-w-0 flex-col p-1 group-data-[collapsible=icon]:p-0 group-data-[collapsible=icon]:items-center\", local.class)}\n {...rest}\n >\n {local.children}\n </div>\n );\n};\n\nexport interface SidebarGroupLabelProps extends JSX.HTMLAttributes<HTMLDivElement> {\n class?: string;\n}\n\nexport const SidebarGroupLabel: ParentComponent<SidebarGroupLabelProps> = (props) => {\n const [local, rest] = splitProps(props, [\"class\", \"children\"]);\n\n return (\n <div\n data-sidebar=\"group-label\"\n class={cn(\n \"flex h-7 shrink-0 items-center rounded-md px-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground outline-hidden transition-all duration-200 ease-linear group-data-[collapsible=icon]:hidden\",\n local.class\n )}\n {...rest}\n >\n {local.children}\n </div>\n );\n};\n\nexport interface SidebarGroupActionProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {\n class?: string;\n}\n\nexport const SidebarGroupAction: ParentComponent<SidebarGroupActionProps> = (props) => {\n const [local, rest] = splitProps(props, [\"class\", \"children\"]);\n\n return (\n <button\n type=\"button\"\n data-sidebar=\"group-action\"\n class={cn(\n \"absolute right-3 top-3 flex size-5 items-center justify-center rounded-md p-0 text-muted-foreground outline-hidden transition-transform hover:bg-muted hover:text-foreground cursor-pointer group-data-[collapsible=icon]:hidden\",\n local.class\n )}\n {...rest}\n >\n {local.children}\n </button>\n );\n};\n\nexport const SidebarGroupContent: ParentComponent<SidebarSectionProps> = (props) => {\n const [local, rest] = splitProps(props, [\"class\", \"children\"]);\n\n return (\n <div\n data-sidebar=\"group-content\"\n class={cn(\"w-full text-sm\", local.class)}\n {...rest}\n >\n {local.children}\n </div>\n );\n};\n\n/* --- 9. Sidebar Menu --- */\nexport interface SidebarMenuProps extends JSX.HTMLAttributes<HTMLUListElement> {\n class?: string;\n}\n\nexport const SidebarMenu: ParentComponent<SidebarMenuProps> = (props) => {\n const [local, rest] = splitProps(props, [\"class\", \"children\"]);\n\n return (\n <ul\n data-sidebar=\"menu\"\n class={cn(\"flex w-full min-w-0 flex-col gap-1 list-none p-0 m-0 group-data-[collapsible=icon]:items-center\", local.class)}\n {...rest}\n >\n {local.children}\n </ul>\n );\n};\n\nexport interface SidebarMenuItemProps extends JSX.HTMLAttributes<HTMLLIElement> {\n class?: string;\n}\n\nexport const SidebarMenuItem: ParentComponent<SidebarMenuItemProps> = (props) => {\n const [local, rest] = splitProps(props, [\"class\", \"children\"]);\n\n return (\n <li\n data-sidebar=\"menu-item\"\n class={cn(\"group/menu-item relative flex w-full justify-center\", local.class)}\n {...rest}\n >\n {local.children}\n </li>\n );\n};\n\n/* --- 10. SidebarMenuButton --- */\nexport const sidebarMenuButtonVariants = cva(\n \"peer/menu-button flex w-full items-center gap-2.5 overflow-hidden rounded-md p-2 text-left text-sm font-medium outline-hidden ring-sidebar-ring transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 active:bg-muted disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-muted data-[active=true]:font-semibold data-[active=true]:text-foreground group-data-[collapsible=icon]:size-9 group-data-[collapsible=icon]:p-0 group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:[&>span]:hidden cursor-pointer select-none\",\n {\n variants: {\n variant: {\n default: \"hover:bg-muted hover:text-foreground\",\n outline: \"bg-background shadow-2xs hover:bg-muted hover:text-foreground border border-border\",\n },\n size: {\n default: \"h-8 text-sm\",\n sm: \"h-7 text-xs\",\n lg: \"h-10 text-sm\",\n },\n },\n defaultVariants: {\n variant: \"default\",\n size: \"default\",\n },\n }\n);\n\nexport interface SidebarMenuButtonProps\n extends JSX.ButtonHTMLAttributes<HTMLButtonElement>,\n VariantProps<typeof sidebarMenuButtonVariants> {\n isActive?: boolean;\n tooltip?: string;\n class?: string;\n}\n\nexport const SidebarMenuButton: ParentComponent<SidebarMenuButtonProps> = (props) => {\n const [local, rest] = splitProps(props, [\n \"isActive\",\n \"tooltip\",\n \"variant\",\n \"size\",\n \"class\",\n \"children\",\n ]);\n\n const sidebar = useSidebar();\n const isCollapsed = () => sidebar.state() === \"collapsed\";\n const tooltipPlacement = () => (sidebar.side() === \"right\" ? \"left\" : \"right\");\n\n const buttonElement = () => (\n <button\n type=\"button\"\n data-sidebar=\"menu-button\"\n data-size={local.size}\n data-active={local.isActive ? \"true\" : \"false\"}\n class={cn(\n sidebarMenuButtonVariants({ variant: local.variant, size: local.size }),\n local.class\n )}\n {...rest}\n >\n {local.children}\n </button>\n );\n\n return (\n <Show\n when={local.tooltip && isCollapsed() && !sidebar.isMobile()}\n fallback={buttonElement()}\n >\n <Tooltip placement={tooltipPlacement()} gutter={8} openDelay={100}>\n <TooltipTrigger as=\"div\" class=\"w-full flex items-center justify-center\">\n {buttonElement()}\n </TooltipTrigger>\n <TooltipContent>\n {local.tooltip}\n </TooltipContent>\n </Tooltip>\n </Show>\n );\n};\n\n/* --- 11. SidebarMenuAction & Badge --- */\nexport interface SidebarMenuActionProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {\n showOnHover?: boolean;\n class?: string;\n}\n\nexport const SidebarMenuAction: ParentComponent<SidebarMenuActionProps> = (props) => {\n const [local, rest] = splitProps(props, [\"showOnHover\", \"class\", \"children\"]);\n\n return (\n <button\n type=\"button\"\n data-sidebar=\"menu-action\"\n class={cn(\n \"absolute right-1 top-1.5 flex size-5 items-center justify-center rounded-md p-0 text-muted-foreground outline-hidden transition-transform hover:bg-muted hover:text-foreground cursor-pointer group-data-[collapsible=icon]:hidden\",\n local.showOnHover && \"opacity-0 group-hover/menu-item:opacity-100 transition-opacity\",\n local.class\n )}\n {...rest}\n >\n {local.children}\n </button>\n );\n};\n\nexport interface SidebarMenuBadgeProps extends JSX.HTMLAttributes<HTMLDivElement> {\n class?: string;\n}\n\nexport const SidebarMenuBadge: ParentComponent<SidebarMenuBadgeProps> = (props) => {\n const [local, rest] = splitProps(props, [\"class\", \"children\"]);\n\n return (\n <div\n data-sidebar=\"menu-badge\"\n class={cn(\n \"pointer-events-none absolute right-1.5 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1.5 text-[11px] font-medium tabular-nums bg-muted text-muted-foreground group-data-[collapsible=icon]:hidden\",\n local.class\n )}\n {...rest}\n >\n {local.children}\n </div>\n );\n};\n\nexport interface SidebarMenuSkeletonProps extends JSX.HTMLAttributes<HTMLDivElement> {\n showIcon?: boolean;\n class?: string;\n}\n\nexport const SidebarMenuSkeleton: Component<SidebarMenuSkeletonProps> = (props) => {\n const [local, rest] = splitProps(props, [\"showIcon\", \"class\"]);\n\n return (\n <div\n data-sidebar=\"menu-skeleton\"\n class={cn(\"flex h-8 items-center gap-2 rounded-md px-2 group-data-[collapsible=icon]:p-0 group-data-[collapsible=icon]:justify-center\", local.class)}\n {...rest}\n >\n <Show when={local.showIcon ?? true}>\n <Skeleton class=\"size-4 rounded-md shrink-0\" />\n </Show>\n <Skeleton class=\"h-4 flex-1 max-w-[80%] group-data-[collapsible=icon]:hidden\" />\n </div>\n );\n};\n\n/* --- 12. Nested Submenus --- */\nexport interface SidebarMenuSubProps extends JSX.HTMLAttributes<HTMLUListElement> {\n class?: string;\n}\n\nexport const SidebarMenuSub: ParentComponent<SidebarMenuSubProps> = (props) => {\n const [local, rest] = splitProps(props, [\"class\", \"children\"]);\n\n return (\n <ul\n data-sidebar=\"menu-sub\"\n class={cn(\n \"mx-3.5 flex min-w-0 flex-col gap-1 border-l border-border/60 px-2.5 py-0.5 list-none group-data-[collapsible=icon]:hidden\",\n local.class\n )}\n {...rest}\n >\n {local.children}\n </ul>\n );\n};\n\nexport const SidebarMenuSubItem: ParentComponent<SidebarMenuItemProps> = (props) => {\n const [local, rest] = splitProps(props, [\"class\", \"children\"]);\n\n return (\n <li class={cn(\"relative\", local.class)} {...rest}>\n {local.children}\n </li>\n );\n};\n\nexport interface SidebarMenuSubButtonProps extends JSX.AnchorHTMLAttributes<HTMLAnchorElement> {\n isActive?: boolean;\n size?: \"sm\" | \"md\";\n class?: string;\n}\n\nexport const SidebarMenuSubButton: ParentComponent<SidebarMenuSubButtonProps> = (props) => {\n const [local, rest] = splitProps(props, [\"isActive\", \"size\", \"class\", \"children\"]);\n\n return (\n <a\n data-sidebar=\"menu-sub-button\"\n data-size={local.size ?? \"md\"}\n data-active={local.isActive ? \"true\" : \"false\"}\n class={cn(\n \"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-xs text-muted-foreground outline-hidden hover:bg-muted hover:text-foreground focus-visible:ring-1 focus-visible:ring-ring data-[active=true]:bg-muted data-[active=true]:font-medium data-[active=true]:text-foreground\",\n local.size === \"sm\" && \"text-[11px]\",\n local.class\n )}\n {...rest}\n >\n {local.children}\n </a>\n );\n};\n",
20
+ "content": "import {\n createContext,\n createSignal,\n createMemo,\n createEffect,\n useContext,\n splitProps,\n onMount,\n onCleanup,\n type Component,\n type JSX,\n type ParentComponent,\n type Accessor,\n Show,\n} from \"solid-js\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport { PanelLeft, PanelRight } from \"lucide-solid\";\nimport { Tooltip, TooltipTrigger, TooltipContent } from \"@/components/ui/tooltip\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport { Separator } from \"@/components/ui/separator\";\nimport { cn } from \"@/lib/cn\";\n\n/* --- Constants --- */\nconst SIDEBAR_COOKIE_NAME = \"nikala_sidebar_state\";\nconst SIDEBAR_WIDTH = \"17rem\";\nconst SIDEBAR_WIDTH_ICON = \"3.5rem\";\nconst SIDEBAR_KEYBOARD_SHORTCUT = \"b\";\n\n/* --- 1. Context & Types --- */\nexport type SidebarState = \"expanded\" | \"collapsed\";\nexport type SidebarSide = \"left\" | \"right\";\n\nexport interface SidebarContextValue {\n state: Accessor<SidebarState>;\n open: Accessor<boolean>;\n setOpen: (open: boolean) => void;\n openMobile: Accessor<boolean>;\n setOpenMobile: (open: boolean) => void;\n isMobile: Accessor<boolean>;\n toggleSidebar: () => void;\n side: Accessor<SidebarSide>;\n setSide: (side: SidebarSide) => void;\n}\n\nconst SidebarContext = createContext<SidebarContextValue>();\n\nconst fallbackSidebarContext: SidebarContextValue = {\n state: () => \"expanded\",\n open: () => true,\n setOpen: () => {},\n openMobile: () => false,\n setOpenMobile: () => {},\n isMobile: () => false,\n toggleSidebar: () => {},\n side: () => \"left\",\n setSide: () => {},\n};\n\nexport function useSidebar() {\n const context = useContext(SidebarContext);\n return context ?? fallbackSidebarContext;\n}\n\n/* --- 2. SidebarProvider --- */\nexport interface SidebarProviderProps extends JSX.HTMLAttributes<HTMLDivElement> {\n defaultOpen?: boolean;\n open?: Accessor<boolean>;\n onOpenChange?: (open: boolean) => void;\n side?: SidebarSide;\n class?: string;\n style?: JSX.CSSProperties;\n}\n\nexport const SidebarProvider: ParentComponent<SidebarProviderProps> = (props) => {\n const [local, rest] = splitProps(props, [\n \"defaultOpen\",\n \"open\",\n \"onOpenChange\",\n \"side\",\n \"class\",\n \"style\",\n \"children\",\n ]);\n\n const [internalOpen, setInternalOpen] = createSignal<boolean>(local.defaultOpen ?? true);\n const [openMobile, setOpenMobile] = createSignal<boolean>(false);\n const [isMobile, setIsMobile] = createSignal<boolean>(false);\n const [side, setSide] = createSignal<SidebarSide>(local.side ?? \"left\");\n\n createEffect(() => {\n if (local.side) {\n setSide(local.side);\n }\n });\n\n const open = () => (local.open !== undefined ? local.open() : internalOpen());\n\n const setOpen = (value: boolean) => {\n if (local.open === undefined) {\n setInternalOpen(value);\n }\n local.onOpenChange?.(value);\n\n if (typeof document !== \"undefined\") {\n document.cookie = `${SIDEBAR_COOKIE_NAME}=${value}; path=/; max-age=${60 * 60 * 24 * 7}`;\n }\n };\n\n const toggleSidebar = () => {\n setOpen(!open());\n };\n\n const state = createMemo<SidebarState>(() => (open() ? \"expanded\" : \"collapsed\"));\n\n onMount(() => {\n if (typeof window === \"undefined\") return;\n\n const checkMobile = () => {\n setIsMobile(window.innerWidth < 768);\n };\n\n checkMobile();\n window.addEventListener(\"resize\", checkMobile, { passive: true });\n\n // Keyboard shortcut (⌘B / Ctrl+B)\n const handleKeyDown = (e: KeyboardEvent) => {\n if (\n (e.metaKey || e.ctrlKey) &&\n e.key.toLowerCase() === SIDEBAR_KEYBOARD_SHORTCUT &&\n !e.defaultPrevented\n ) {\n e.preventDefault();\n toggleSidebar();\n }\n };\n\n window.addEventListener(\"keydown\", handleKeyDown);\n\n onCleanup(() => {\n window.removeEventListener(\"resize\", checkMobile);\n window.removeEventListener(\"keydown\", handleKeyDown);\n });\n });\n\n const contextValue: SidebarContextValue = {\n state,\n open,\n setOpen,\n openMobile,\n setOpenMobile,\n isMobile,\n toggleSidebar,\n side,\n setSide,\n };\n\n return (\n <SidebarContext.Provider value={contextValue}>\n <div\n style={{\n \"--sidebar-width\": SIDEBAR_WIDTH,\n \"--sidebar-width-icon\": SIDEBAR_WIDTH_ICON,\n ...(typeof local.style === \"object\" ? local.style : {}),\n } as JSX.CSSProperties}\n class={cn(\n \"group/sidebar-wrapper relative flex w-full max-w-full\",\n local.class\n )}\n {...rest}\n >\n {local.children}\n </div>\n </SidebarContext.Provider>\n );\n};\n\n/* --- 3. Sidebar Root --- */\nexport interface SidebarProps extends JSX.HTMLAttributes<HTMLDivElement> {\n side?: SidebarSide;\n variant?: \"sidebar\" | \"floating\" | \"inset\";\n collapsible?: \"offcanvas\" | \"icon\" | \"none\";\n class?: string;\n}\n\nexport const Sidebar: ParentComponent<SidebarProps> = (props) => {\n const [local, rest] = splitProps(props, [\n \"side\",\n \"variant\",\n \"collapsible\",\n \"class\",\n \"children\",\n ]);\n\n const sidebar = useSidebar();\n const side = () => local.side ?? sidebar.side();\n const variant = () => local.variant ?? \"sidebar\";\n const collapsible = () => local.collapsible ?? \"icon\";\n\n createEffect(() => {\n if (local.side) {\n sidebar.setSide(local.side);\n }\n });\n\n const isCollapsed = () => sidebar.state() === \"collapsed\" && collapsible() !== \"none\";\n\n return (\n <div\n data-state={isCollapsed() ? \"collapsed\" : \"expanded\"}\n data-collapsible={isCollapsed() ? collapsible() : \"\"}\n data-variant={variant()}\n data-side={side()}\n class={cn(\n \"group relative flex flex-col bg-card text-card-foreground transition-[width,opacity] duration-200 ease-in-out shrink-0\",\n !isCollapsed()\n ? \"w-[var(--sidebar-width,17rem)]\"\n : collapsible() === \"icon\"\n ? \"w-[var(--sidebar-width-icon,3.5rem)]\"\n : \"w-0 overflow-hidden opacity-0 border-none\",\n // Side borders\n variant() === \"sidebar\" && (side() === \"left\" ? \"border-r border-border\" : \"border-l border-border\"),\n // Floating variant\n variant() === \"floating\" && \"m-2 rounded-lg border border-border shadow-md\",\n // Inset variant\n variant() === \"inset\" && \"m-2 rounded-lg border border-border/80 bg-card shadow-2xs\",\n local.class\n )}\n {...rest}\n >\n <div\n data-sidebar=\"sidebar\"\n class=\"flex h-full w-full flex-col overflow-hidden\"\n >\n {local.children}\n </div>\n </div>\n );\n};\n\n/* --- 4. SidebarTrigger --- */\nexport interface SidebarTriggerProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {\n class?: string;\n}\n\nexport const SidebarTrigger: Component<SidebarTriggerProps> = (props) => {\n const [local, rest] = splitProps(props, [\"class\", \"onClick\"]);\n const { toggleSidebar, side } = useSidebar();\n\n return (\n <button\n type=\"button\"\n aria-label=\"Toggle Sidebar\"\n onClick={(e) => {\n if (typeof local.onClick === \"function\") {\n local.onClick(e);\n }\n toggleSidebar();\n }}\n class={cn(\n \"inline-flex size-7 items-center justify-center rounded-md border border-border/60 bg-background text-foreground hover:bg-muted focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring cursor-pointer transition-colors shadow-2xs\",\n local.class\n )}\n {...rest}\n >\n <Show when={side() === \"right\"} fallback={<PanelLeft class=\"size-4\" />}>\n <PanelRight class=\"size-4\" />\n </Show>\n <span class=\"sr-only\">Toggle Sidebar</span>\n </button>\n );\n};\n\n/* --- 5. SidebarRail --- */\nexport interface SidebarRailProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {\n class?: string;\n}\n\nexport const SidebarRail: Component<SidebarRailProps> = (props) => {\n const [local, rest] = splitProps(props, [\"class\"]);\n const { toggleSidebar } = useSidebar();\n\n return (\n <button\n type=\"button\"\n data-sidebar=\"rail\"\n aria-label=\"Toggle Sidebar Rail\"\n tabIndex={-1}\n onClick={toggleSidebar}\n title=\"Toggle Sidebar\"\n class={cn(\n \"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex cursor-w-resize\",\n local.class\n )}\n {...rest}\n />\n );\n};\n\n/* --- 6. SidebarInset --- */\nexport interface SidebarInsetProps extends JSX.HTMLAttributes<HTMLElement> {\n class?: string;\n}\n\nexport const SidebarInset: ParentComponent<SidebarInsetProps> = (props) => {\n const [local, rest] = splitProps(props, [\"class\", \"children\"]);\n\n return (\n <main\n class={cn(\n \"relative flex min-h-0 flex-1 flex-col bg-background\",\n local.class\n )}\n {...rest}\n >\n {local.children}\n </main>\n );\n};\n\n/* --- 7. Sidebar Structural Sections --- */\nexport interface SidebarSectionProps extends JSX.HTMLAttributes<HTMLDivElement> {\n class?: string;\n}\n\nexport const SidebarHeader: ParentComponent<SidebarSectionProps> = (props) => {\n const [local, rest] = splitProps(props, [\"class\", \"children\"]);\n\n return (\n <div\n data-sidebar=\"header\"\n class={cn(\"flex flex-col gap-2 p-3 border-b border-border/40 shrink-0 group-data-[collapsible=icon]:p-2 group-data-[collapsible=icon]:items-center\", local.class)}\n {...rest}\n >\n {local.children}\n </div>\n );\n};\n\nexport const SidebarFooter: ParentComponent<SidebarSectionProps> = (props) => {\n const [local, rest] = splitProps(props, [\"class\", \"children\"]);\n\n return (\n <div\n data-sidebar=\"footer\"\n class={cn(\"flex flex-col gap-2 p-3 mt-auto border-t border-border/40 shrink-0 group-data-[collapsible=icon]:p-2 group-data-[collapsible=icon]:items-center\", local.class)}\n {...rest}\n >\n {local.children}\n </div>\n );\n};\n\nexport const SidebarSeparator: Component<SidebarSectionProps> = (props) => {\n const [local, rest] = splitProps(props, [\"class\"]);\n\n return (\n <Separator\n data-sidebar=\"separator\"\n class={cn(\"mx-2 w-auto bg-border/50 my-1 group-data-[collapsible=icon]:mx-1\", local.class)}\n {...rest}\n />\n );\n};\n\nexport const SidebarContent: ParentComponent<SidebarSectionProps> = (props) => {\n const [local, rest] = splitProps(props, [\"class\", \"children\"]);\n\n return (\n <div\n data-sidebar=\"content\"\n class={cn(\n \"flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto p-2 group-data-[collapsible=icon]:p-1 group-data-[collapsible=icon]:overflow-hidden group-data-[collapsible=icon]:items-center\",\n local.class\n )}\n {...rest}\n >\n {local.children}\n </div>\n );\n};\n\n/* --- 8. Sidebar Groups --- */\nexport const SidebarGroup: ParentComponent<SidebarSectionProps> = (props) => {\n const [local, rest] = splitProps(props, [\"class\", \"children\"]);\n\n return (\n <div\n data-sidebar=\"group\"\n class={cn(\"relative flex w-full min-w-0 flex-col p-1 group-data-[collapsible=icon]:p-0 group-data-[collapsible=icon]:items-center\", local.class)}\n {...rest}\n >\n {local.children}\n </div>\n );\n};\n\nexport interface SidebarGroupLabelProps extends JSX.HTMLAttributes<HTMLDivElement> {\n class?: string;\n}\n\nexport const SidebarGroupLabel: ParentComponent<SidebarGroupLabelProps> = (props) => {\n const [local, rest] = splitProps(props, [\"class\", \"children\"]);\n\n return (\n <div\n data-sidebar=\"group-label\"\n class={cn(\n \"flex h-7 shrink-0 items-center rounded-md px-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground outline-hidden transition-all duration-200 ease-linear group-data-[collapsible=icon]:hidden\",\n local.class\n )}\n {...rest}\n >\n {local.children}\n </div>\n );\n};\n\nexport interface SidebarGroupActionProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {\n class?: string;\n}\n\nexport const SidebarGroupAction: ParentComponent<SidebarGroupActionProps> = (props) => {\n const [local, rest] = splitProps(props, [\"class\", \"children\"]);\n\n return (\n <button\n type=\"button\"\n data-sidebar=\"group-action\"\n class={cn(\n \"absolute right-3 top-3 flex size-5 items-center justify-center rounded-md p-0 text-muted-foreground outline-hidden transition-transform hover:bg-muted hover:text-foreground cursor-pointer group-data-[collapsible=icon]:hidden\",\n local.class\n )}\n {...rest}\n >\n {local.children}\n </button>\n );\n};\n\nexport const SidebarGroupContent: ParentComponent<SidebarSectionProps> = (props) => {\n const [local, rest] = splitProps(props, [\"class\", \"children\"]);\n\n return (\n <div\n data-sidebar=\"group-content\"\n class={cn(\"w-full text-sm\", local.class)}\n {...rest}\n >\n {local.children}\n </div>\n );\n};\n\n/* --- 9. Sidebar Menu --- */\nexport interface SidebarMenuProps extends JSX.HTMLAttributes<HTMLUListElement> {\n class?: string;\n}\n\nexport const SidebarMenu: ParentComponent<SidebarMenuProps> = (props) => {\n const [local, rest] = splitProps(props, [\"class\", \"children\"]);\n\n return (\n <ul\n data-sidebar=\"menu\"\n class={cn(\"flex w-full min-w-0 flex-col gap-1 list-none p-0 m-0 group-data-[collapsible=icon]:items-center\", local.class)}\n {...rest}\n >\n {local.children}\n </ul>\n );\n};\n\nexport interface SidebarMenuItemProps extends JSX.HTMLAttributes<HTMLLIElement> {\n class?: string;\n}\n\nexport const SidebarMenuItem: ParentComponent<SidebarMenuItemProps> = (props) => {\n const [local, rest] = splitProps(props, [\"class\", \"children\"]);\n\n return (\n <li\n data-sidebar=\"menu-item\"\n class={cn(\"group/menu-item relative flex w-full justify-center\", local.class)}\n {...rest}\n >\n {local.children}\n </li>\n );\n};\n\n/* --- 10. SidebarMenuButton --- */\nexport const sidebarMenuButtonVariants = cva(\n \"peer/menu-button flex w-full items-center gap-2.5 overflow-hidden rounded-md p-2 text-left text-sm font-medium outline-hidden ring-sidebar-ring transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 active:bg-muted disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-muted data-[active=true]:font-semibold data-[active=true]:text-foreground group-data-[collapsible=icon]:size-9 group-data-[collapsible=icon]:p-0 group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:[&>span]:hidden cursor-pointer select-none\",\n {\n variants: {\n variant: {\n default: \"hover:bg-muted hover:text-foreground\",\n outline: \"bg-background shadow-2xs hover:bg-muted hover:text-foreground border border-border\",\n },\n size: {\n default: \"h-8 text-sm\",\n sm: \"h-7 text-xs\",\n lg: \"h-10 text-sm\",\n },\n },\n defaultVariants: {\n variant: \"default\",\n size: \"default\",\n },\n }\n);\n\nexport interface SidebarMenuButtonProps\n extends JSX.ButtonHTMLAttributes<HTMLButtonElement>,\n VariantProps<typeof sidebarMenuButtonVariants> {\n isActive?: boolean;\n tooltip?: string;\n class?: string;\n}\n\nexport const SidebarMenuButton: ParentComponent<SidebarMenuButtonProps> = (props) => {\n const [local, rest] = splitProps(props, [\n \"isActive\",\n \"tooltip\",\n \"variant\",\n \"size\",\n \"class\",\n \"children\",\n ]);\n\n const sidebar = useSidebar();\n const isCollapsed = () => sidebar.state() === \"collapsed\";\n const tooltipPlacement = () => (sidebar.side() === \"right\" ? \"left\" : \"right\");\n\n const buttonElement = () => (\n <button\n type=\"button\"\n data-sidebar=\"menu-button\"\n data-size={local.size}\n data-active={local.isActive ? \"true\" : \"false\"}\n class={cn(\n sidebarMenuButtonVariants({ variant: local.variant, size: local.size }),\n local.class\n )}\n {...rest}\n >\n {local.children}\n </button>\n );\n\n return (\n <Show\n when={local.tooltip && isCollapsed() && !sidebar.isMobile()}\n fallback={buttonElement()}\n >\n <Tooltip placement={tooltipPlacement()} gutter={8} openDelay={100}>\n <TooltipTrigger as=\"div\" class=\"w-full flex items-center justify-center\">\n {buttonElement()}\n </TooltipTrigger>\n <TooltipContent>\n {local.tooltip}\n </TooltipContent>\n </Tooltip>\n </Show>\n );\n};\n\n/* --- 11. SidebarMenuAction & Badge --- */\nexport interface SidebarMenuActionProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {\n showOnHover?: boolean;\n class?: string;\n}\n\nexport const SidebarMenuAction: ParentComponent<SidebarMenuActionProps> = (props) => {\n const [local, rest] = splitProps(props, [\"showOnHover\", \"class\", \"children\"]);\n\n return (\n <button\n type=\"button\"\n data-sidebar=\"menu-action\"\n class={cn(\n \"absolute right-1 top-1.5 flex size-5 items-center justify-center rounded-md p-0 text-muted-foreground outline-hidden transition-transform hover:bg-muted hover:text-foreground cursor-pointer group-data-[collapsible=icon]:hidden\",\n local.showOnHover && \"opacity-0 group-hover/menu-item:opacity-100 transition-opacity\",\n local.class\n )}\n {...rest}\n >\n {local.children}\n </button>\n );\n};\n\nexport interface SidebarMenuBadgeProps extends JSX.HTMLAttributes<HTMLDivElement> {\n class?: string;\n}\n\nexport const SidebarMenuBadge: ParentComponent<SidebarMenuBadgeProps> = (props) => {\n const [local, rest] = splitProps(props, [\"class\", \"children\"]);\n\n return (\n <div\n data-sidebar=\"menu-badge\"\n class={cn(\n \"pointer-events-none absolute right-1.5 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1.5 text-[11px] font-medium tabular-nums bg-muted text-muted-foreground group-data-[collapsible=icon]:hidden\",\n local.class\n )}\n {...rest}\n >\n {local.children}\n </div>\n );\n};\n\nexport interface SidebarMenuSkeletonProps extends JSX.HTMLAttributes<HTMLDivElement> {\n showIcon?: boolean;\n class?: string;\n}\n\nexport const SidebarMenuSkeleton: Component<SidebarMenuSkeletonProps> = (props) => {\n const [local, rest] = splitProps(props, [\"showIcon\", \"class\"]);\n\n return (\n <div\n data-sidebar=\"menu-skeleton\"\n class={cn(\"flex h-8 items-center gap-2 rounded-md px-2 group-data-[collapsible=icon]:p-0 group-data-[collapsible=icon]:justify-center\", local.class)}\n {...rest}\n >\n <Show when={local.showIcon ?? true}>\n <Skeleton class=\"size-4 rounded-md shrink-0\" />\n </Show>\n <Skeleton class=\"h-4 flex-1 max-w-[80%] group-data-[collapsible=icon]:hidden\" />\n </div>\n );\n};\n\n/* --- 12. Nested Submenus --- */\nexport interface SidebarMenuSubProps extends JSX.HTMLAttributes<HTMLUListElement> {\n class?: string;\n}\n\nexport const SidebarMenuSub: ParentComponent<SidebarMenuSubProps> = (props) => {\n const [local, rest] = splitProps(props, [\"class\", \"children\"]);\n\n return (\n <ul\n data-sidebar=\"menu-sub\"\n class={cn(\n \"mx-3.5 flex min-w-0 flex-col gap-1 border-l border-border/60 px-2.5 py-0.5 list-none group-data-[collapsible=icon]:hidden\",\n local.class\n )}\n {...rest}\n >\n {local.children}\n </ul>\n );\n};\n\nexport const SidebarMenuSubItem: ParentComponent<SidebarMenuItemProps> = (props) => {\n const [local, rest] = splitProps(props, [\"class\", \"children\"]);\n\n return (\n <li class={cn(\"relative\", local.class)} {...rest}>\n {local.children}\n </li>\n );\n};\n\nexport interface SidebarMenuSubButtonProps extends JSX.AnchorHTMLAttributes<HTMLAnchorElement> {\n isActive?: boolean;\n size?: \"sm\" | \"md\";\n class?: string;\n}\n\nexport const SidebarMenuSubButton: ParentComponent<SidebarMenuSubButtonProps> = (props) => {\n const [local, rest] = splitProps(props, [\"isActive\", \"size\", \"class\", \"children\"]);\n\n return (\n <a\n data-sidebar=\"menu-sub-button\"\n data-size={local.size ?? \"md\"}\n data-active={local.isActive ? \"true\" : \"false\"}\n class={cn(\n \"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-xs text-muted-foreground outline-hidden hover:bg-muted hover:text-foreground focus-visible:ring-1 focus-visible:ring-ring data-[active=true]:bg-muted data-[active=true]:font-medium data-[active=true]:text-foreground\",\n local.size === \"sm\" && \"text-[11px]\",\n local.class\n )}\n {...rest}\n >\n {local.children}\n </a>\n );\n};\n",
21
21
  "type": "registry:ui"
22
22
  }
23
23
  ]
@@ -11,7 +11,7 @@
11
11
  "files": [
12
12
  {
13
13
  "path": "ui/status.tsx",
14
- "content": "import { Show, splitProps, type Component, type JSX } from \"solid-js\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport { cn } from \"@/lib/cn\";\n\nexport const statusVariants = cva(\n \"inline-flex items-center gap-2 text-sm font-medium text-foreground\",\n {\n variants: {\n variant: {\n neutral: \"text-muted-foreground\",\n success: \"text-emerald-700 dark:text-emerald-400\",\n warning: \"text-amber-700 dark:text-amber-400\",\n error: \"text-destructive\",\n info: \"text-blue-700 dark:text-blue-400\",\n },\n size: {\n sm: \"text-xs gap-1.5\",\n default: \"text-sm gap-2\",\n },\n bordered: {\n true: \"rounded-md border px-2 py-1\",\n false: \"\",\n },\n borderVariant: {\n neutral: \"border-muted-foreground/30\",\n success: \"border-emerald-500/30\",\n warning: \"border-amber-500/30\",\n error: \"border-destructive/30\",\n info: \"border-blue-500/30\",\n },\n },\n defaultVariants: {\n variant: \"neutral\",\n size: \"default\",\n bordered: false,\n borderVariant: \"neutral\",\n },\n }\n);\n\nexport const statusDotVariants = cva(\"size-2 shrink-0 rounded-lg\", {\n variants: {\n variant: {\n neutral: \"bg-muted-foreground\",\n success: \"bg-emerald-500\",\n warning: \"bg-amber-500\",\n error: \"bg-destructive\",\n info: \"bg-blue-500\",\n },\n size: {\n sm: \"size-1.5\",\n default: \"size-2\",\n },\n animation: {\n none: \"\",\n pulse: \"animate-pulse\",\n ping: \"\",\n },\n },\n defaultVariants: {\n variant: \"neutral\",\n size: \"default\",\n animation: \"none\",\n },\n});\n\nexport interface StatusProps\n extends JSX.HTMLAttributes<HTMLSpanElement>,\n VariantProps<typeof statusVariants> {\n class?: string;\n animation?: \"none\" | \"pulse\" | \"ping\";\n}\n\n/** A compact status indicator combining a semantic color dot and label. */\nexport const Status: Component<StatusProps> = (props) => {\n const [local, rest] = splitProps(props, [\n \"variant\",\n \"size\",\n \"class\",\n \"children\",\n \"animation\",\n \"bordered\",\n ]);\n\n return (\n <span\n role=\"status\"\n class={cn(\n statusVariants({\n variant: local.variant,\n size: local.size,\n bordered: local.bordered,\n borderVariant: local.variant,\n }),\n local.class\n )}\n {...rest}\n >\n <span class=\"relative flex shrink-0\" aria-hidden=\"true\">\n <Show when={local.animation === \"ping\"}>\n <span\n class={cn(\n \"absolute inline-flex size-full animate-ping rounded-lg opacity-75\",\n statusDotVariants({ variant: local.variant, size: local.size })\n )}\n />\n </Show>\n <span\n class={statusDotVariants({\n variant: local.variant,\n size: local.size,\n animation: local.animation === \"ping\" ? \"none\" : local.animation,\n })}\n />\n </span>\n {local.children}\n </span>\n );\n};\n",
14
+ "content": "import { Show, splitProps, type Component, type JSX } from \"solid-js\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport { cn } from \"@/lib/cn\";\n\nexport const statusVariants = cva(\n \"inline-flex items-center gap-2 text-sm font-medium text-foreground\",\n {\n variants: {\n variant: {\n neutral: \"text-muted-foreground\",\n success: \"text-emerald-700 dark:text-emerald-400\",\n warning: \"text-amber-700 dark:text-amber-400\",\n error: \"text-destructive\",\n info: \"text-blue-700 dark:text-blue-400\",\n },\n size: {\n sm: \"text-xs gap-1.5\",\n default: \"text-sm gap-2\",\n },\n bordered: {\n true: \"rounded-md border px-2 py-1\",\n false: \"\",\n },\n borderVariant: {\n neutral: \"border-muted-foreground/30\",\n success: \"border-emerald-500/30\",\n warning: \"border-amber-500/30\",\n error: \"border-destructive/30\",\n info: \"border-blue-500/30\",\n },\n },\n defaultVariants: {\n variant: \"neutral\",\n size: \"default\",\n bordered: false,\n borderVariant: \"neutral\",\n },\n }\n);\n\nexport const statusDotVariants = cva(\"size-2 shrink-0 rounded-lg\", {\n variants: {\n variant: {\n neutral: \"bg-muted-foreground\",\n success: \"bg-emerald-500\",\n warning: \"bg-amber-500\",\n error: \"bg-destructive\",\n info: \"bg-blue-500\",\n },\n size: {\n sm: \"size-1.5\",\n default: \"size-2\",\n },\n animation: {\n none: \"\",\n pulse: \"animate-pulse\",\n ping: \"\",\n },\n },\n defaultVariants: {\n variant: \"neutral\",\n size: \"default\",\n animation: \"none\",\n },\n});\n\nexport interface StatusProps\n extends JSX.HTMLAttributes<HTMLSpanElement>,\n VariantProps<typeof statusVariants> {\n class?: string;\n animation?: \"none\" | \"pulse\" | \"ping\";\n}\n\n/** A compact status indicator combining a semantic color dot and label. */\nexport const Status: Component<StatusProps> = (props) => {\n const [local, rest] = splitProps(props, [\n \"variant\",\n \"size\",\n \"class\",\n \"children\",\n \"animation\",\n \"bordered\",\n ]);\n\n return (\n <span\n role=\"status\"\n class={cn(\n statusVariants({\n variant: local.variant,\n size: local.size,\n bordered: local.bordered,\n borderVariant: local.variant,\n }),\n local.class\n )}\n {...rest}\n >\n <span class=\"relative flex shrink-0\" aria-hidden=\"true\">\n <Show when={local.animation === \"ping\"}>\n <span\n class={cn(\n \"absolute inline-flex size-full animate-ping rounded-lg opacity-75\",\n statusDotVariants({ variant: local.variant, size: local.size })\n )}\n />\n </Show>\n <span\n class={cn(\n statusDotVariants({\n variant: local.variant,\n size: local.size,\n animation: local.animation === \"ping\" ? \"none\" : local.animation,\n })\n )}\n />\n </span>\n {local.children}\n </span>\n );\n};\n",
15
15
  "type": "registry:ui"
16
16
  }
17
17
  ]
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "steps",
3
+ "title": "Steps",
4
+ "description": "A vertical multi-step instruction container with connecting progress line and numbered step indicators.",
5
+ "type": "registry:ui",
6
+ "dependencies": [
7
+ "clsx",
8
+ "tailwind-merge"
9
+ ],
10
+ "registryDependencies": [
11
+ "badge"
12
+ ],
13
+ "files": [
14
+ {
15
+ "path": "ui/steps.tsx",
16
+ "content": "import {\n createContext,\n useContext,\n splitProps,\n Show,\n type JSX,\n type ParentComponent,\n} from \"solid-js\";\nimport { Badge, type BadgeProps } from \"@/components/ui/badge\";\nimport { cn } from \"@/lib/cn\";\n\nexport type StepVariant =\n | \"default\"\n | \"primary\"\n | \"info\"\n | \"success\"\n | \"warning\"\n | \"danger\"\n | \"secondary\"\n | \"outline\"\n | \"destructive\"\n | \"purple\";\n\nfunction toBadgeVariant(variant?: StepVariant): NonNullable<BadgeProps[\"variant\"]> {\n switch (variant) {\n case \"primary\":\n return \"default\";\n case \"danger\":\n return \"destructive\";\n case \"default\":\n return \"outline\";\n case \"info\":\n return \"info\";\n case \"success\":\n return \"success\";\n case \"warning\":\n return \"warning\";\n case \"secondary\":\n return \"secondary\";\n default:\n return \"outline\";\n }\n}\nexport type StepOrientation = \"horizontal\" | \"vertical\";\n\ninterface StepsContextValue {\n variant?: StepVariant;\n orientation?: () => StepOrientation;\n}\n\nconst StepsContext = createContext<StepsContextValue>();\n\nexport interface StepsProps extends JSX.HTMLAttributes<HTMLDivElement> {\n /** Layout orientation: vertical (default) or horizontal */\n orientation?: StepOrientation;\n /** Default color variant for all steps in this container */\n variant?: StepVariant;\n class?: string;\n}\n\n/**\n * Multi-step instruction and stepper container supporting vertical and horizontal layouts.\n */\nexport const Steps: ParentComponent<StepsProps> = (props) => {\n const [local, rest] = splitProps(props, [\"orientation\", \"variant\", \"class\", \"children\"]);\n\n const orientation = () => local.orientation || \"vertical\";\n\n return (\n <StepsContext.Provider value={{ variant: local.variant, orientation }}>\n <div\n class={cn(\n \"relative my-6 [counter-reset:step]\",\n orientation() === \"horizontal\"\n ? \"flex flex-row items-start justify-between w-full\"\n : \"ml-4 border-l border-border/80 pl-8 space-y-8\",\n local.class\n )}\n {...rest}\n >\n {/* Single continuous horizontal rail in parent container */}\n <Show when={orientation() === \"horizontal\"}>\n <div class=\"absolute left-8 right-8 top-3.5 h-0.5 bg-border/80 z-0\" />\n </Show>\n\n {local.children}\n </div>\n </StepsContext.Provider>\n );\n};\n\nexport interface StepProps extends JSX.HTMLAttributes<HTMLDivElement> {\n /** Optional custom step number, icon, or label to override automatic CSS counter */\n step?: string | number | JSX.Element;\n /** Optional step title rendered at the top of the step */\n title?: string;\n /** Step color variant */\n variant?: StepVariant;\n class?: string;\n}\n\nexport const Step: ParentComponent<StepProps> = (props) => {\n const [local, rest] = splitProps(props, [\"step\", \"title\", \"variant\", \"class\", \"children\"]);\n const context = useContext(StepsContext);\n\n const effectiveVariant = () => local.variant || context?.variant || \"default\";\n const isHorizontal = () => context?.orientation?.() === \"horizontal\";\n\n return (\n <Show\n when={isHorizontal()}\n fallback={\n <div class={cn(\"relative\", local.class)} {...rest}>\n {/* Numbered Step Badge with geometric center on left vertical border line */}\n <Badge\n variant={toBadgeVariant(effectiveVariant())}\n class={cn(\n \"absolute -left-[2.875rem] top-0 size-7 p-0 flex items-center justify-center rounded-lg font-mono text-xs font-semibold shadow-2xs select-none\",\n !local.step && \"[counter-increment:step] before:content-[counter(step)]\"\n )}\n >\n <Show when={local.step}>\n {local.step}\n </Show>\n </Badge>\n\n {/* Step Title Header */}\n <Show when={local.title}>\n <h4 class=\"font-semibold text-base tracking-tight text-foreground mb-2\">\n {local.title}\n </h4>\n </Show>\n\n {/* Step Body Content */}\n <div class=\"text-sm leading-relaxed text-muted-foreground space-y-3\">\n {local.children}\n </div>\n </div>\n }\n >\n <div class={cn(\"flex-1 flex flex-col items-center text-center relative z-10 px-2\", local.class)} {...rest}>\n {/* Step Badge sitting on top of continuous parent rail */}\n <Badge\n variant={toBadgeVariant(effectiveVariant())}\n class={cn(\n \"mb-2.5 size-7 p-0 flex items-center justify-center rounded-lg font-mono text-xs font-semibold shadow-2xs select-none\",\n !local.step && \"[counter-increment:step] before:content-[counter(step)]\"\n )}\n >\n <Show when={local.step}>\n {local.step}\n </Show>\n </Badge>\n\n {/* Step Title Header */}\n <Show when={local.title}>\n <h4 class=\"font-semibold text-sm tracking-tight text-foreground mb-1\">\n {local.title}\n </h4>\n </Show>\n\n {/* Step Body Content */}\n <div class=\"text-xs leading-relaxed text-muted-foreground\">\n {local.children}\n </div>\n </div>\n </Show>\n );\n};\n\nexport interface StepTitleProps extends JSX.HTMLAttributes<HTMLHeadingElement> {\n class?: string;\n}\n\nexport const StepTitle: ParentComponent<StepTitleProps> = (props) => {\n const [local, rest] = splitProps(props, [\"class\", \"children\"]);\n return (\n <h4\n class={cn(\"font-semibold text-base tracking-tight text-foreground mb-2\", local.class)}\n {...rest}\n >\n {local.children}\n </h4>\n );\n};\n\nexport interface StepDescriptionProps extends JSX.HTMLAttributes<HTMLParagraphElement> {\n class?: string;\n}\n\nexport const StepDescription: ParentComponent<StepDescriptionProps> = (props) => {\n const [local, rest] = splitProps(props, [\"class\", \"children\"]);\n return (\n <div class={cn(\"text-sm leading-relaxed text-muted-foreground\", local.class)} {...rest}>\n {local.children}\n </div>\n );\n};\n",
17
+ "type": "registry:ui"
18
+ }
19
+ ]
20
+ }
@@ -7,10 +7,13 @@
7
7
  "clsx",
8
8
  "tailwind-merge"
9
9
  ],
10
+ "registryDependencies": [
11
+ "create-controllable-signal"
12
+ ],
10
13
  "files": [
11
14
  {
12
15
  "path": "ui/switch.tsx",
13
- "content": "import { splitProps, type Component, type JSX } from \"solid-js\";\nimport { createControllableSignal } from \"@nikala-ui/hooks\";\nimport { cn } from \"@/lib/cn\";\n\nexport interface SwitchProps\n extends Omit<JSX.ButtonHTMLAttributes<HTMLButtonElement>, \"onChange\"> {\n /** Controlled checked state */\n checked?: boolean;\n /** Uncontrolled default checked state */\n defaultChecked?: boolean;\n /** Event handler triggered when toggle state changes */\n onChange?: (checked: boolean) => void;\n class?: string;\n}\n\n/**\n * Nikala UI Switch component for boolean toggle inputs built for SolidJS with Tailwind CSS v4 styling.\n */\nexport const Switch: Component<SwitchProps> = (props) => {\n const [local, rest] = splitProps(props, [\n \"checked\",\n \"defaultChecked\",\n \"onChange\",\n \"disabled\",\n \"class\",\n \"onClick\",\n ]);\n\n const [isChecked, setIsChecked] = createControllableSignal({\n value: () => local.checked,\n defaultValue: local.defaultChecked ?? false,\n onChange: (val) => local.onChange?.(val),\n });\n\n const toggle = (\n e: MouseEvent & { currentTarget: HTMLButtonElement; target: Element }\n ) => {\n if (local.disabled) return;\n setIsChecked(!isChecked());\n\n if (typeof local.onClick === \"function\") {\n local.onClick(e);\n }\n };\n\n return (\n <button\n type=\"button\"\n role=\"switch\"\n aria-checked={isChecked()}\n data-state={isChecked() ? \"checked\" : \"unchecked\"}\n disabled={local.disabled}\n onClick={toggle}\n class={cn(\n \"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-lg border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-muted\",\n local.class\n )}\n {...rest}\n >\n <span\n data-state={isChecked() ? \"checked\" : \"unchecked\"}\n class={cn(\n \"pointer-events-none block h-4 w-4 rounded-sm bg-primary-foreground shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0\"\n )}\n />\n </button>\n );\n};",
16
+ "content": "import { splitProps, type Component, type JSX } from \"solid-js\";\nimport { createControllableSignal } from \"@/hooks/create-controllable-signal\";\nimport { cn } from \"@/lib/cn\";\n\nexport interface SwitchProps\n extends Omit<JSX.ButtonHTMLAttributes<HTMLButtonElement>, \"onChange\"> {\n /** Controlled checked state */\n checked?: boolean;\n /** Uncontrolled default checked state */\n defaultChecked?: boolean;\n /** Event handler triggered when toggle state changes */\n onChange?: (checked: boolean) => void;\n class?: string;\n}\n\n/**\n * Nikala UI Switch component for boolean toggle inputs built for SolidJS with Tailwind CSS v4 styling.\n */\nexport const Switch: Component<SwitchProps> = (props) => {\n const [local, rest] = splitProps(props, [\n \"checked\",\n \"defaultChecked\",\n \"onChange\",\n \"disabled\",\n \"class\",\n \"onClick\",\n ]);\n\n const [isChecked, setIsChecked] = createControllableSignal({\n value: () => local.checked,\n defaultValue: local.defaultChecked ?? false,\n onChange: (val) => local.onChange?.(val),\n });\n\n const toggle = (\n e: MouseEvent & { currentTarget: HTMLButtonElement; target: Element }\n ) => {\n if (local.disabled) return;\n setIsChecked(!isChecked());\n\n if (typeof local.onClick === \"function\") {\n local.onClick(e);\n }\n };\n\n return (\n <button\n type=\"button\"\n role=\"switch\"\n aria-checked={isChecked()}\n data-state={isChecked() ? \"checked\" : \"unchecked\"}\n disabled={local.disabled}\n onClick={toggle}\n class={cn(\n \"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-lg border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-muted\",\n local.class\n )}\n {...rest}\n >\n <span\n data-state={isChecked() ? \"checked\" : \"unchecked\"}\n class={cn(\n \"pointer-events-none block h-4 w-4 rounded-sm bg-primary-foreground shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0\"\n )}\n />\n </button>\n );\n};",
14
17
  "type": "registry:ui"
15
18
  }
16
19
  ]
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "table-of-contents",
3
+ "title": "Table of Contents",
4
+ "description": "An accessible heading navigation tree with ScrollSpy tracking and smooth scrolling, composed of ScrollArea and List.",
5
+ "type": "registry:ui",
6
+ "dependencies": [
7
+ "clsx",
8
+ "tailwind-merge",
9
+ "class-variance-authority"
10
+ ],
11
+ "registryDependencies": [
12
+ "scroll-area",
13
+ "list",
14
+ "create-scroll-position"
15
+ ],
16
+ "files": [
17
+ {
18
+ "path": "ui/table-of-contents.tsx",
19
+ "content": "// src/components/ui/table-of-contents.tsx\nimport {\n splitProps,\n createSignal,\n createEffect,\n onMount,\n onCleanup,\n For,\n Show,\n type Component,\n type JSX,\n} from \"solid-js\";\nimport { ScrollArea } from \"@/components/ui/scroll-area\";\nimport { List, ListHeader, ListItem } from \"@/components/ui/list\";\nimport { cn } from \"@/lib/cn\";\nimport { createScrollPosition } from \"@/hooks/create-scroll-position\";\n\nexport interface TocItem {\n id: string;\n text: string;\n depth: number;\n}\n\nexport interface TableOfContentsProps extends Omit<JSX.HTMLAttributes<HTMLDivElement>, \"title\"> {\n items: TocItem[];\n title?: string | false;\n activeId?: string;\n onItemClick?: (id: string) => void;\n class?: string;\n}\n\n/**\n * Nikala UI TableOfContents Component.\n * Pure composition of ScrollArea, ListHeader, List, and ListItem with active ScrollSpy tracking.\n */\nexport const TableOfContents: Component<TableOfContentsProps> = (props) => {\n const [local, rest] = splitProps(props, [\n \"items\",\n \"title\",\n \"activeId\",\n \"onItemClick\",\n \"class\",\n ]);\n\n const [activeId, setActiveId] = createSignal<string>(local.activeId || \"\");\n const scrollPosition = createScrollPosition();\n\n onMount(() => {\n if (typeof window === \"undefined\" || typeof document === \"undefined\") return;\n\n const headings = (local.items || [])\n .map((item) => document.getElementById(item.id))\n .filter((element): element is HTMLElement => Boolean(element));\n\n const updateActiveHeading = () => {\n if (headings.length === 0) return;\n\n const atPageBottom = scrollPosition.isAtBottom();\n if (atPageBottom) {\n setActiveId(headings[headings.length - 1].id);\n return;\n }\n\n const heading = [...headings]\n .reverse()\n .find((element) => element.getBoundingClientRect().top <= 120);\n\n setActiveId(heading?.id || headings[0].id);\n };\n\n createEffect(() => {\n scrollPosition.y();\n scrollPosition.isAtBottom();\n updateActiveHeading();\n });\n\n window.addEventListener(\"resize\", updateActiveHeading);\n onCleanup(() => window.removeEventListener(\"resize\", updateActiveHeading));\n });\n\n const handleClick = (e: MouseEvent, id: string) => {\n if (typeof document !== \"undefined\") {\n const el = document.getElementById(id);\n if (el) {\n e.preventDefault();\n el.scrollIntoView({ behavior: \"smooth\" });\n setActiveId(id);\n if (typeof window !== \"undefined\") {\n window.history.pushState(null, \"\", `#${id}`);\n }\n }\n }\n local.onItemClick?.(id);\n };\n\n return (\n <ScrollArea class={cn(\"h-full py-2\", local.class)} {...rest}>\n <Show when={local.title !== false}>\n <ListHeader\n title={typeof local.title === \"string\" ? local.title : \"On this page\"}\n class=\"px-3 pb-1\"\n />\n </Show>\n\n <List class=\"gap-0.5 p-1\">\n <For each={local.items}>\n {(item) => (\n <ListItem\n title={item.text}\n href={`#${item.id}`}\n active={activeId() === item.id}\n size=\"sm\"\n hoverVariant=\"muted\"\n class={cn(\n \"text-xs transition-colors py-1 h-auto min-h-0\",\n item.depth === 3 && \"pl-5 text-muted-foreground/80\",\n item.depth > 3 && \"pl-7 text-muted-foreground/60\",\n activeId() === item.id\n ? \"text-primary font-medium bg-primary/10\"\n : \"text-muted-foreground hover:text-foreground\"\n )}\n onClick={(e: MouseEvent) => handleClick(e, item.id)}\n />\n )}\n </For>\n </List>\n </ScrollArea>\n );\n};\n\nexport const Toc = TableOfContents;\n",
20
+ "type": "registry:ui"
21
+ }
22
+ ]
23
+ }
@@ -7,10 +7,13 @@
7
7
  "clsx",
8
8
  "tailwind-merge"
9
9
  ],
10
+ "registryDependencies": [
11
+ "create-controllable-signal"
12
+ ],
10
13
  "files": [
11
14
  {
12
15
  "path": "ui/tabs.tsx",
13
- "content": "import {\n createContext,\n useContext,\n splitProps,\n Show,\n type Component,\n type JSX,\n type Accessor,\n} from \"solid-js\";\nimport { createControllableSignal } from \"@nikala-ui/hooks\";\nimport { cn } from \"@/lib/cn\";\n\ninterface TabsContextValue {\n value: Accessor<string | undefined>;\n setValue: (value: string) => void;\n orientation: Accessor<\"horizontal\" | \"vertical\">;\n}\n\nconst TabsContext = createContext<TabsContextValue>();\n\nexport interface TabsProps\n extends Omit<JSX.HTMLAttributes<HTMLDivElement>, \"onChange\"> {\n /** Controlled active tab value */\n value?: string;\n /** Uncontrolled default active tab value */\n defaultValue?: string;\n /** Callback fired when active tab changes */\n onChange?: (value: string) => void;\n /** Layout orientation of tab triggers and content */\n orientation?: \"horizontal\" | \"vertical\";\n class?: string;\n}\n\n/**\n * Root Tabs container component managing active tab context state and orientation.\n */\nexport const Tabs: Component<TabsProps> = (props) => {\n const [local, rest] = splitProps(props, [\n \"value\",\n \"defaultValue\",\n \"onChange\",\n \"orientation\",\n \"class\",\n \"children\",\n ]);\n\n const [currentValue, setCurrentValue] = createControllableSignal<string>({\n value: () => local.value,\n defaultValue: local.defaultValue,\n onChange: (val) => local.onChange?.(val),\n });\n\n const orientation = () => local.orientation || \"horizontal\";\n\n const contextValue: TabsContextValue = {\n value: currentValue,\n setValue: (val: string) => setCurrentValue(val),\n orientation,\n };\n\n return (\n <TabsContext.Provider value={contextValue}>\n <div\n data-orientation={orientation()}\n class={cn(\n \"w-full\",\n orientation() === \"vertical\" ? \"flex flex-row gap-4\" : \"flex flex-col gap-2\",\n local.class\n )}\n {...rest}\n >\n {local.children}\n </div>\n </TabsContext.Provider>\n );\n};\n\nexport interface TabsListProps extends JSX.HTMLAttributes<HTMLDivElement> {\n class?: string;\n}\n\n/**\n * Container wrapper for Tab triggers.\n */\nexport const TabsList: Component<TabsListProps> = (props) => {\n const [local, rest] = splitProps(props, [\"class\", \"children\"]);\n const context = useContext(TabsContext);\n\n const isVertical = () => context?.orientation() === \"vertical\";\n\n return (\n <div\n role=\"tablist\"\n aria-orientation={context?.orientation() || \"horizontal\"}\n class={cn(\n \"inline-flex rounded-lg bg-muted p-1 text-muted-foreground\",\n isVertical()\n ? \"flex-col h-auto w-auto items-stretch justify-start\"\n : \"h-9 items-center justify-center\",\n local.class\n )}\n {...rest}\n >\n {local.children}\n </div>\n );\n};\n\nexport interface TabsTriggerProps\n extends Omit<JSX.ButtonHTMLAttributes<HTMLButtonElement>, \"onChange\"> {\n /** Unique value identifier for this tab */\n value: string;\n class?: string;\n}\n\n/**\n * Tab button trigger to activate a specific tab panel.\n */\nexport const TabsTrigger: Component<TabsTriggerProps> = (props) => {\n const [local, rest] = splitProps(props, [\n \"value\",\n \"disabled\",\n \"class\",\n \"children\",\n \"onClick\",\n ]);\n const context = useContext(TabsContext);\n\n if (!context) {\n throw new Error(\"TabsTrigger must be used within a Tabs component\");\n }\n\n const isSelected = () => context.value() === local.value;\n const isVertical = () => context.orientation() === \"vertical\";\n\n const handleClick = (\n e: MouseEvent & { currentTarget: HTMLButtonElement; target: Element }\n ) => {\n if (local.disabled) return;\n context.setValue(local.value);\n if (typeof local.onClick === \"function\") {\n local.onClick(e);\n }\n };\n\n return (\n <button\n type=\"button\"\n role=\"tab\"\n aria-selected={isSelected()}\n data-state={isSelected() ? \"active\" : \"inactive\"}\n data-orientation={context.orientation()}\n disabled={local.disabled}\n onClick={handleClick}\n class={cn(\n \"inline-flex items-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-accent data-[state=active]:border data-[state=active]:border-border data-[state=active]:text-foreground data-[state=active]:shadow-sm cursor-pointer\",\n isVertical() ? \"justify-start py-1.5\" : \"justify-center\",\n local.class\n )}\n {...rest}\n >\n {local.children}\n </button>\n );\n};\n\nexport interface TabsContentProps extends JSX.HTMLAttributes<HTMLDivElement> {\n /** Value matching the corresponding tab trigger */\n value: string;\n class?: string;\n}\n\n/**\n * Content panel revealed when the associated tab is active.\n */\nexport const TabsContent: Component<TabsContentProps> = (props) => {\n const [local, rest] = splitProps(props, [\"value\", \"class\", \"children\"]);\n const context = useContext(TabsContext);\n\n if (!context) {\n throw new Error(\"TabsContent must be used within a Tabs component\");\n }\n\n const isSelected = () => context.value() === local.value;\n const isVertical = () => context.orientation() === \"vertical\";\n\n return (\n <Show when={isSelected()}>\n <div\n role=\"tabpanel\"\n data-state={isSelected() ? \"active\" : \"inactive\"}\n data-orientation={context.orientation()}\n class={cn(\n \"ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\",\n isVertical() ? \"flex-1 mt-0\" : \"mt-2\",\n local.class\n )}\n {...rest}\n >\n {local.children}\n </div>\n </Show>\n );\n};",
16
+ "content": "import {\n createContext,\n useContext,\n splitProps,\n Show,\n type Component,\n type JSX,\n type Accessor,\n} from \"solid-js\";\nimport { createControllableSignal } from \"@/hooks/create-controllable-signal\";\nimport { cn } from \"@/lib/cn\";\n\ninterface TabsContextValue {\n value: Accessor<string | undefined>;\n setValue: (value: string) => void;\n orientation: Accessor<\"horizontal\" | \"vertical\">;\n}\n\nconst TabsContext = createContext<TabsContextValue>();\n\nexport interface TabsProps\n extends Omit<JSX.HTMLAttributes<HTMLDivElement>, \"onChange\"> {\n /** Controlled active tab value */\n value?: string;\n /** Uncontrolled default active tab value */\n defaultValue?: string;\n /** Callback fired when active tab changes */\n onChange?: (value: string) => void;\n /** Layout orientation of tab triggers and content */\n orientation?: \"horizontal\" | \"vertical\";\n class?: string;\n}\n\n/**\n * Root Tabs container component managing active tab context state and orientation.\n */\nexport const Tabs: Component<TabsProps> = (props) => {\n const [local, rest] = splitProps(props, [\n \"value\",\n \"defaultValue\",\n \"onChange\",\n \"orientation\",\n \"class\",\n \"children\",\n ]);\n\n const [currentValue, setCurrentValue] = createControllableSignal<string>({\n value: () => local.value,\n defaultValue: local.defaultValue,\n onChange: (val) => local.onChange?.(val),\n });\n\n const orientation = () => local.orientation || \"horizontal\";\n\n const contextValue: TabsContextValue = {\n value: currentValue,\n setValue: (val: string) => setCurrentValue(val),\n orientation,\n };\n\n return (\n <TabsContext.Provider value={contextValue}>\n <div\n data-orientation={orientation()}\n class={cn(\n \"w-full\",\n orientation() === \"vertical\" ? \"flex flex-row gap-4\" : \"flex flex-col gap-2\",\n local.class\n )}\n {...rest}\n >\n {local.children}\n </div>\n </TabsContext.Provider>\n );\n};\n\nexport interface TabsListProps extends JSX.HTMLAttributes<HTMLDivElement> {\n class?: string;\n}\n\n/**\n * Container wrapper for Tab triggers.\n */\nexport const TabsList: Component<TabsListProps> = (props) => {\n const [local, rest] = splitProps(props, [\"class\", \"children\"]);\n const context = useContext(TabsContext);\n\n const isVertical = () => context?.orientation() === \"vertical\";\n\n return (\n <div\n role=\"tablist\"\n aria-orientation={context?.orientation() || \"horizontal\"}\n class={cn(\n \"inline-flex rounded-lg bg-muted p-1 text-muted-foreground\",\n isVertical()\n ? \"flex-col h-auto w-auto items-stretch justify-start\"\n : \"h-9 items-center justify-center\",\n local.class\n )}\n {...rest}\n >\n {local.children}\n </div>\n );\n};\n\nexport interface TabsTriggerProps\n extends Omit<JSX.ButtonHTMLAttributes<HTMLButtonElement>, \"onChange\"> {\n /** Unique value identifier for this tab */\n value: string;\n class?: string;\n}\n\n/**\n * Tab button trigger to activate a specific tab panel.\n */\nexport const TabsTrigger: Component<TabsTriggerProps> = (props) => {\n const [local, rest] = splitProps(props, [\n \"value\",\n \"disabled\",\n \"class\",\n \"children\",\n \"onClick\",\n ]);\n const context = useContext(TabsContext);\n\n if (!context) {\n throw new Error(\"TabsTrigger must be used within a Tabs component\");\n }\n\n const isSelected = () => context.value() === local.value;\n const isVertical = () => context.orientation() === \"vertical\";\n\n const handleClick = (\n e: MouseEvent & { currentTarget: HTMLButtonElement; target: Element }\n ) => {\n if (local.disabled) return;\n context.setValue(local.value);\n if (typeof local.onClick === \"function\") {\n local.onClick(e);\n }\n };\n\n return (\n <button\n type=\"button\"\n role=\"tab\"\n aria-selected={isSelected()}\n data-state={isSelected() ? \"active\" : \"inactive\"}\n data-orientation={context.orientation()}\n disabled={local.disabled}\n onClick={handleClick}\n class={cn(\n \"inline-flex items-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-accent data-[state=active]:border data-[state=active]:border-border data-[state=active]:text-foreground data-[state=active]:shadow-sm cursor-pointer\",\n isVertical() ? \"justify-start py-1.5\" : \"justify-center\",\n local.class\n )}\n {...rest}\n >\n {local.children}\n </button>\n );\n};\n\nexport interface TabsContentProps extends JSX.HTMLAttributes<HTMLDivElement> {\n /** Value matching the corresponding tab trigger */\n value: string;\n class?: string;\n}\n\n/**\n * Content panel revealed when the associated tab is active.\n */\nexport const TabsContent: Component<TabsContentProps> = (props) => {\n const [local, rest] = splitProps(props, [\"value\", \"class\", \"children\"]);\n const context = useContext(TabsContext);\n\n if (!context) {\n throw new Error(\"TabsContent must be used within a Tabs component\");\n }\n\n const isSelected = () => context.value() === local.value;\n const isVertical = () => context.orientation() === \"vertical\";\n\n return (\n <Show when={isSelected()}>\n <div\n role=\"tabpanel\"\n data-state={isSelected() ? \"active\" : \"inactive\"}\n data-orientation={context.orientation()}\n class={cn(\n \"ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\",\n isVertical() ? \"flex-1 mt-0\" : \"mt-2\",\n local.class\n )}\n {...rest}\n >\n {local.children}\n </div>\n </Show>\n );\n};",
14
17
  "type": "registry:ui"
15
18
  }
16
19
  ]
@@ -11,27 +11,28 @@
11
11
  ],
12
12
  "registryDependencies": [
13
13
  "button",
14
- "dropdown-menu"
14
+ "dropdown-menu",
15
+ "create-color-mode"
15
16
  ],
16
17
  "files": [
17
18
  {
18
19
  "path": "providers/theme-provider.tsx",
19
- "content": "import {\n createContext,\n createEffect,\n createSignal,\n onCleanup,\n onMount,\n useContext,\n type ParentComponent,\n type Accessor,\n} from \"solid-js\";\n\nexport type Theme = \"light\" | \"dark\" | \"system\";\nexport type AccentColor = \"wine\" | \"violet\" | \"sky\" | \"emerald\" | \"rose\" | \"amber\" | \"zinc\";\nexport type Radius = \"0\" | \"0.3\" | \"0.5\" | \"0.75\" | \"1.0\";\n\nexport interface ThemeProviderProps {\n /** Initial default theme mode if no saved preference is found in localStorage */\n defaultTheme?: Theme;\n /** Initial default accent color override if needed */\n defaultAccent?: AccentColor;\n /** Initial default border radius override if needed */\n defaultRadius?: Radius;\n /** Key used to store theme preferences in localStorage */\n storageKey?: string;\n}\n\nconst ACCENT_COLORS: Record<\n AccentColor,\n { light: string; dark: string; lightFg: string; darkFg: string }\n> = {\n wine: { light: \"#722f37\", dark: \"#9e3b47\", lightFg: \"#ffffff\", darkFg: \"#ffffff\" },\n violet: { light: \"#7c3aed\", dark: \"#8b5cf6\", lightFg: \"#ffffff\", darkFg: \"#ffffff\" },\n sky: { light: \"#0284c7\", dark: \"#38bdf8\", lightFg: \"#ffffff\", darkFg: \"#0f172a\" },\n emerald: { light: \"#059669\", dark: \"#34d399\", lightFg: \"#ffffff\", darkFg: \"#052e16\" },\n rose: { light: \"#e11d48\", dark: \"#fb7185\", lightFg: \"#ffffff\", darkFg: \"#ffffff\" },\n amber: { light: \"#d97706\", dark: \"#fbbf24\", lightFg: \"#ffffff\", darkFg: \"#111827\" },\n zinc: { light: \"#18181b\", dark: \"#fafafa\", lightFg: \"#fafafa\", darkFg: \"#18181b\" },\n};\n\ninterface ThemeProviderContextValue {\n theme: Accessor<Theme>;\n setTheme: (theme: Theme) => void;\n accent: Accessor<AccentColor | undefined>;\n setAccent: (accent: AccentColor) => void;\n radius: Accessor<Radius | undefined>;\n setRadius: (radius: Radius) => void;\n}\n\nconst ThemeProviderContext = createContext<ThemeProviderContextValue>();\n\n/**\n * Context provider managing application theme state (light/dark/system), accent colors, and border radius.\n */\nexport const ThemeProvider: ParentComponent<ThemeProviderProps> = (props) => {\n const storageKey = props.storageKey || \"nikala-theme\";\n const defaultTheme = props.defaultTheme || \"system\";\n\n // Safely read saved values from localStorage without forcing unnecessary fallbacks\n const getInitialTheme = (): Theme => {\n if (typeof window === \"undefined\") return defaultTheme;\n try {\n const saved = localStorage.getItem(`${storageKey}-mode`);\n if (saved === \"light\" || saved === \"dark\" || saved === \"system\") return saved;\n } catch { }\n return defaultTheme;\n };\n\n const getInitialAccent = (): AccentColor | undefined => {\n if (typeof window === \"undefined\") return props.defaultAccent;\n try {\n const saved = localStorage.getItem(`${storageKey}-accent`);\n if (saved && ACCENT_COLORS[saved as AccentColor]) return saved as AccentColor;\n } catch { }\n return props.defaultAccent;\n };\n\n const getInitialRadius = (): Radius | undefined => {\n if (typeof window === \"undefined\") return props.defaultRadius;\n try {\n const saved = localStorage.getItem(`${storageKey}-radius`);\n if (saved) return saved as Radius;\n } catch { }\n return props.defaultRadius;\n };\n\n const [theme, setThemeSignal] = createSignal<Theme>(getInitialTheme());\n const [accent, setAccentSignal] = createSignal<AccentColor | undefined>(getInitialAccent());\n const [radius, setRadiusSignal] = createSignal<Radius | undefined>(getInitialRadius());\n\n // Applies classes and CSS custom properties when custom overrides are explicitly set\n const applyTheme = (\n targetTheme: Theme,\n currentAccent: AccentColor | undefined,\n currentRadius: Radius | undefined\n ) => {\n if (typeof window === \"undefined\") return;\n\n const root = document.documentElement;\n root.classList.remove(\"light\", \"dark\");\n\n let resolvedDark = false;\n if (targetTheme === \"system\") {\n resolvedDark = window.matchMedia(\"(prefers-color-scheme: dark)\").matches;\n root.classList.add(resolvedDark ? \"dark\" : \"light\");\n } else {\n resolvedDark = targetTheme === \"dark\";\n root.classList.add(targetTheme);\n }\n\n // Override --primary CSS variables ONLY if explicitly chosen\n if (currentAccent && ACCENT_COLORS[currentAccent]) {\n const accentData = ACCENT_COLORS[currentAccent];\n const primaryHex = resolvedDark ? accentData.dark : accentData.light;\n const primaryFgHex = resolvedDark ? accentData.darkFg : accentData.lightFg;\n\n root.style.setProperty(\"--primary\", primaryHex);\n root.style.setProperty(\"--primary-foreground\", primaryFgHex);\n }\n\n // Override --radius CSS variable ONLY if explicitly chosen\n if (currentRadius) {\n root.style.setProperty(\"--radius\", `${currentRadius}rem`);\n }\n };\n\n // Reactively apply theme updates and store preferences in localStorage\n createEffect(() => {\n const t = theme();\n const a = accent();\n const r = radius();\n\n applyTheme(t, a, r);\n\n if (typeof window !== \"undefined\") {\n try {\n localStorage.setItem(`${storageKey}-mode`, t);\n if (a) localStorage.setItem(`${storageKey}-accent`, a);\n if (r) localStorage.setItem(`${storageKey}-radius`, r);\n } catch { }\n }\n });\n\n onMount(() => {\n if (typeof window === \"undefined\") return;\n\n const mediaQuery = window.matchMedia(\"(prefers-color-scheme: dark)\");\n\n const handleSystemChange = (e: MediaQueryListEvent) => {\n if (theme() === \"system\") {\n const root = document.documentElement;\n root.classList.remove(\"light\", \"dark\");\n root.classList.add(e.matches ? \"dark\" : \"light\");\n\n const currentAccent = accent();\n if (currentAccent && ACCENT_COLORS[currentAccent]) {\n const accentData = ACCENT_COLORS[currentAccent];\n const primaryHex = e.matches ? accentData.dark : accentData.light;\n const primaryFgHex = e.matches ? accentData.darkFg : accentData.lightFg;\n\n root.style.setProperty(\"--primary\", primaryHex);\n root.style.setProperty(\"--primary-foreground\", primaryFgHex);\n }\n }\n };\n\n if (mediaQuery.addEventListener) {\n mediaQuery.addEventListener(\"change\", handleSystemChange);\n } else if (\"addListener\" in mediaQuery) {\n (mediaQuery as any).addListener(handleSystemChange);\n }\n\n onCleanup(() => {\n if (mediaQuery.removeEventListener) {\n mediaQuery.removeEventListener(\"change\", handleSystemChange);\n } else if (\"removeListener\" in mediaQuery) {\n (mediaQuery as any).removeListener(handleSystemChange);\n }\n });\n });\n\n const value: ThemeProviderContextValue = {\n theme,\n setTheme: (newTheme: Theme) => setThemeSignal(newTheme),\n accent,\n setAccent: (newAccent: AccentColor) => setAccentSignal(newAccent),\n radius,\n setRadius: (newRadius: Radius) => setRadiusSignal(newRadius),\n };\n\n return (\n <ThemeProviderContext.Provider value={value}>\n {props.children}\n </ThemeProviderContext.Provider>\n );\n};\n\n/**\n * Accesses Nikala UI theme state, accent colors, border radius, and update functions.\n */\nexport function useTheme(): ThemeProviderContextValue {\n const context = useContext(ThemeProviderContext);\n if (!context) {\n throw new Error(\"useTheme must be used within a ThemeProvider\");\n }\n return context;\n}\n\nexport { ThemeScript, type ThemeScriptProps } from \"./theme-script\";",
20
+ "content": "import {\n createContext,\n createEffect,\n createSignal,\n onCleanup,\n onMount,\n useContext,\n type ParentComponent,\n type Accessor,\n} from \"solid-js\";\n\nexport type Theme = \"light\" | \"dark\" | \"system\";\nexport type AccentColor = \"yellow\" | \"red\" | \"violet\" | \"sky\" | \"emerald\" | \"zinc\";\nexport type Radius = \"0\" | \"0.3\" | \"0.5\" | \"0.75\" | \"1.0\";\n\nexport interface ThemeProviderProps {\n /** Initial default theme mode if no saved preference is found in localStorage */\n defaultTheme?: Theme;\n /** Initial default accent color override if needed */\n defaultAccent?: AccentColor;\n /** Initial default border radius override if needed */\n defaultRadius?: Radius;\n /** Key used to store theme preferences in localStorage */\n storageKey?: string;\n}\n\nconst ACCENT_COLORS: Record<\n AccentColor,\n { light: string; dark: string; lightFg: string; darkFg: string }\n> = {\n yellow: { light: \"oklch(0.795 0.184 86.047)\", dark: \"oklch(0.852 0.199 91.936)\", lightFg: \"oklch(0.145 0 0)\", darkFg: \"oklch(0.145 0 0)\" },\n red: { light: \"oklch(0.577 0.245 27.325)\", dark: \"oklch(0.637 0.237 25.331)\", lightFg: \"oklch(0.985 0 0)\", darkFg: \"oklch(0.985 0 0)\" },\n violet: { light: \"oklch(0.541 0.281 293.009)\", dark: \"oklch(0.606 0.25 292.717)\", lightFg: \"oklch(0.985 0 0)\", darkFg: \"oklch(0.985 0 0)\" },\n sky: { light: \"oklch(0.588 0.158 241.966)\", dark: \"oklch(0.672 0.154 238.29)\", lightFg: \"oklch(0.985 0 0)\", darkFg: \"oklch(0.145 0 0)\" },\n emerald: { light: \"oklch(0.596 0.145 163.225)\", dark: \"oklch(0.696 0.17 162.48)\", lightFg: \"oklch(0.985 0 0)\", darkFg: \"oklch(0.145 0 0)\" },\n zinc: { light: \"oklch(0.205 0 0)\", dark: \"oklch(0.985 0 0)\", lightFg: \"oklch(0.985 0 0)\", darkFg: \"oklch(0.205 0 0)\" },\n};\n\ninterface ThemeProviderContextValue {\n theme: Accessor<Theme>;\n setTheme: (theme: Theme) => void;\n accent: Accessor<AccentColor | undefined>;\n setAccent: (accent: AccentColor) => void;\n radius: Accessor<Radius | undefined>;\n setRadius: (radius: Radius) => void;\n}\n\nconst ThemeProviderContext = createContext<ThemeProviderContextValue>();\n\n/**\n * Context provider managing application theme state (light/dark/system), accent colors, and border radius.\n */\nexport const ThemeProvider: ParentComponent<ThemeProviderProps> = (props) => {\n const storageKey = props.storageKey || \"nikala-theme\";\n const defaultTheme = props.defaultTheme || \"system\";\n\n // Safely read saved values from localStorage without forcing unnecessary fallbacks\n const getInitialTheme = (): Theme => {\n if (typeof window === \"undefined\") return defaultTheme;\n try {\n const saved = localStorage.getItem(`${storageKey}-mode`);\n if (saved === \"light\" || saved === \"dark\" || saved === \"system\") return saved;\n } catch { }\n return defaultTheme;\n };\n\n const getInitialAccent = (): AccentColor | undefined => {\n if (typeof window === \"undefined\") return props.defaultAccent;\n try {\n const saved = localStorage.getItem(`${storageKey}-accent`);\n if (saved && ACCENT_COLORS[saved as AccentColor]) return saved as AccentColor;\n } catch { }\n return props.defaultAccent;\n };\n\n const getInitialRadius = (): Radius | undefined => {\n if (typeof window === \"undefined\") return props.defaultRadius;\n try {\n const saved = localStorage.getItem(`${storageKey}-radius`);\n if (saved) return saved as Radius;\n } catch { }\n return props.defaultRadius;\n };\n\n const [theme, setThemeSignal] = createSignal<Theme>(getInitialTheme());\n const [accent, setAccentSignal] = createSignal<AccentColor | undefined>(getInitialAccent());\n const [radius, setRadiusSignal] = createSignal<Radius | undefined>(getInitialRadius());\n\n // Applies classes and CSS custom properties when custom overrides are explicitly set\n const applyTheme = (\n targetTheme: Theme,\n currentAccent: AccentColor | undefined,\n currentRadius: Radius | undefined\n ) => {\n if (typeof window === \"undefined\") return;\n\n const root = document.documentElement;\n root.classList.remove(\"light\", \"dark\");\n\n let resolvedDark = false;\n if (targetTheme === \"system\") {\n resolvedDark = window.matchMedia(\"(prefers-color-scheme: dark)\").matches;\n root.classList.add(resolvedDark ? \"dark\" : \"light\");\n } else {\n resolvedDark = targetTheme === \"dark\";\n root.classList.add(targetTheme);\n }\n root.style.colorScheme = resolvedDark ? \"dark\" : \"light\";\n\n // Override --primary CSS variables ONLY if explicitly chosen\n if (currentAccent && ACCENT_COLORS[currentAccent]) {\n const accentData = ACCENT_COLORS[currentAccent];\n const primaryHex = resolvedDark ? accentData.dark : accentData.light;\n const primaryFgHex = resolvedDark ? accentData.darkFg : accentData.lightFg;\n\n root.style.setProperty(\"--primary\", primaryHex);\n root.style.setProperty(\"--primary-foreground\", primaryFgHex);\n } else {\n root.style.removeProperty(\"--primary\");\n root.style.removeProperty(\"--primary-foreground\");\n }\n\n // Override --radius CSS variable ONLY if explicitly chosen\n if (currentRadius) {\n root.style.setProperty(\"--radius\", `${currentRadius}rem`);\n } else {\n root.style.removeProperty(\"--radius\");\n }\n };\n\n // Reactively apply theme updates and store preferences in localStorage\n createEffect(() => {\n const t = theme();\n const a = accent();\n const r = radius();\n\n applyTheme(t, a, r);\n\n if (typeof window !== \"undefined\") {\n try {\n localStorage.setItem(`${storageKey}-mode`, t);\n if (a) localStorage.setItem(`${storageKey}-accent`, a);\n else localStorage.removeItem(`${storageKey}-accent`);\n if (r) localStorage.setItem(`${storageKey}-radius`, r);\n else localStorage.removeItem(`${storageKey}-radius`);\n } catch { }\n }\n });\n\n onMount(() => {\n if (typeof window === \"undefined\") return;\n\n const mediaQuery = window.matchMedia(\"(prefers-color-scheme: dark)\");\n\n const handleSystemChange = (e: MediaQueryListEvent) => {\n if (theme() === \"system\") {\n const root = document.documentElement;\n root.classList.remove(\"light\", \"dark\");\n root.classList.add(e.matches ? \"dark\" : \"light\");\n root.style.colorScheme = e.matches ? \"dark\" : \"light\";\n\n const currentAccent = accent();\n if (currentAccent && ACCENT_COLORS[currentAccent]) {\n const accentData = ACCENT_COLORS[currentAccent];\n const primaryHex = e.matches ? accentData.dark : accentData.light;\n const primaryFgHex = e.matches ? accentData.darkFg : accentData.lightFg;\n\n root.style.setProperty(\"--primary\", primaryHex);\n root.style.setProperty(\"--primary-foreground\", primaryFgHex);\n }\n }\n };\n\n if (mediaQuery.addEventListener) {\n mediaQuery.addEventListener(\"change\", handleSystemChange);\n } else if (\"addListener\" in mediaQuery) {\n (mediaQuery as any).addListener(handleSystemChange);\n }\n\n onCleanup(() => {\n if (mediaQuery.removeEventListener) {\n mediaQuery.removeEventListener(\"change\", handleSystemChange);\n } else if (\"removeListener\" in mediaQuery) {\n (mediaQuery as any).removeListener(handleSystemChange);\n }\n });\n });\n\n const value: ThemeProviderContextValue = {\n theme,\n setTheme: (newTheme: Theme) => setThemeSignal(newTheme),\n accent,\n setAccent: (newAccent: AccentColor) => setAccentSignal(newAccent),\n radius,\n setRadius: (newRadius: Radius) => setRadiusSignal(newRadius),\n };\n\n return (\n <ThemeProviderContext.Provider value={value}>\n {props.children}\n </ThemeProviderContext.Provider>\n );\n};\n\n/**\n * Accesses Nikala UI theme state, accent colors, border radius, and update functions.\n */\nexport function useTheme(): ThemeProviderContextValue {\n const context = useContext(ThemeProviderContext);\n if (!context) {\n throw new Error(\"useTheme must be used within a ThemeProvider\");\n }\n return context;\n}\n\nexport { ThemeScript, type ThemeScriptProps } from \"./theme-script\";",
20
21
  "type": "registry:ui"
21
22
  },
22
23
  {
23
24
  "path": "providers/theme-transitions.ts",
24
- "content": "export type ThemeEffect = \"none\" | \"circular\" | \"fade\";\n\n/**\n * Injects required CSS view-transition pseudo-element styles to prevent browser mix-blend artifacts.\n */\nfunction ensureTransitionStyles() {\n if (typeof document === \"undefined\") return;\n const styleId = \"nikala-view-transition-styles\";\n if (!document.getElementById(styleId)) {\n const style = document.createElement(\"style\");\n style.id = styleId;\n style.textContent = `\n ::view-transition-old(root),\n ::view-transition-new(root) {\n animation: none;\n mix-blend-mode: normal;\n }\n ::view-transition-old(root) {\n z-index: 1;\n }\n ::view-transition-new(root) {\n z-index: 9999;\n }\n `;\n document.head.appendChild(style);\n }\n}\n\n/**\n * Executes a theme change with the specified transition effect using the Web View Transitions API.\n *\n * @param effect - Desired transition animation (\"none\", \"circular\", \"fade\")\n * @param event - Mouse or Pointer event to calculate transition center coordinates\n * @param updateThemeCallback - Callback function performing the actual theme state change\n */\nexport function runThemeTransition(\n effect: ThemeEffect = \"none\",\n event: MouseEvent | undefined,\n updateThemeCallback: () => void\n) {\n // Safe fallback if View Transitions API is unsupported or user prefers reduced motion\n if (\n effect === \"none\" ||\n typeof document === \"undefined\" ||\n !(document as any).startViewTransition ||\n window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n ) {\n updateThemeCallback();\n return;\n }\n\n ensureTransitionStyles();\n\n // Circular expanding ripple transition originating from click coordinates\n if (effect === \"circular\" && event) {\n const x = event.clientX;\n const y = event.clientY;\n\n const endRadius = Math.hypot(\n Math.max(x, window.innerWidth - x),\n Math.max(y, window.innerHeight - y)\n );\n\n const transition = (document as any).startViewTransition(() => {\n updateThemeCallback();\n });\n\n transition.ready.then(() => {\n document.documentElement.animate(\n {\n clipPath: [\n `circle(0px at ${x}px ${y}px)`,\n `circle(${endRadius}px at ${x}px ${y}px)`,\n ],\n },\n {\n duration: 500,\n easing: \"ease-in-out\",\n pseudoElement: \"::view-transition-new(root)\",\n }\n );\n });\n return;\n }\n\n // Smooth opacity fade view transition\n if (effect === \"fade\") {\n const transition = (document as any).startViewTransition(() => {\n updateThemeCallback();\n });\n\n transition.ready.then(() => {\n document.documentElement.animate(\n {\n opacity: [0, 1],\n },\n {\n duration: 350,\n easing: \"ease-in-out\",\n pseudoElement: \"::view-transition-new(root)\",\n }\n );\n });\n return;\n }\n\n updateThemeCallback();\n}",
25
+ "content": "export type ThemeEffect = \"none\" | \"circular\" | \"fade\";\n\n/**\n * Injects required CSS view-transition pseudo-element styles to prevent browser mix-blend artifacts.\n */\nfunction ensureTransitionStyles() {\n if (typeof document === \"undefined\") return;\n const styleId = \"nikala-view-transition-styles\";\n if (!document.getElementById(styleId)) {\n const style = document.createElement(\"style\");\n style.id = styleId;\n style.textContent = `\n ::view-transition-old(root),\n ::view-transition-new(root) {\n animation: none;\n mix-blend-mode: normal;\n }\n ::view-transition-old(root) {\n z-index: 1;\n }\n ::view-transition-new(root) {\n z-index: 9999;\n }\n `;\n document.head.appendChild(style);\n }\n}\n\n/**\n * Checks whether the current environment is Linux WebKitGTK (e.g. Tauri on Linux)\n * where CSS View Transitions pseudo-element animations trigger known WebProcess crashes.\n */\nfunction isLinuxWebKit(): boolean {\n if (typeof navigator === \"undefined\") return false;\n const ua = navigator.userAgent || \"\";\n const isLinux = /linux/i.test(ua);\n const isWebKit = /applewebkit/i.test(ua);\n const isChrome = /chrome|chromium|edg/i.test(ua);\n const isTauri = typeof window !== \"undefined\" && typeof (window as any).__TAURI_INTERNALS__ !== \"undefined\";\n\n return isLinux && ((isWebKit && !isChrome) || isTauri);\n}\n\n/**\n * Executes a theme change with the specified transition effect using the Web View Transitions API.\n * Supported on modern browsers, Windows (Tauri WebView2), and macOS (Tauri WKWebView).\n * Automatically falls back to safe instant transitions on Linux WebKitGTK.\n *\n * @param effect - Desired transition animation (\"none\", \"circular\", \"fade\")\n * @param event - Mouse or Pointer event to calculate transition center coordinates\n * @param updateThemeCallback - Callback function performing the actual theme state change\n */\nexport function runThemeTransition(\n effect: ThemeEffect = \"none\",\n event: MouseEvent | undefined,\n updateThemeCallback: () => void\n) {\n // Safe fallback if View Transitions API is unsupported, reduced motion is preferred,\n // or on Linux WebKitGTK where pseudo-element clipping transitions trigger WebProcess faults\n if (\n effect === \"none\" ||\n typeof document === \"undefined\" ||\n !(document as any).startViewTransition ||\n window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches ||\n isLinuxWebKit()\n ) {\n updateThemeCallback();\n return;\n }\n\n ensureTransitionStyles();\n\n try {\n // Circular expanding ripple transition originating from click coordinates\n if (effect === \"circular\" && event) {\n const x = event.clientX;\n const y = event.clientY;\n\n const endRadius = Math.hypot(\n Math.max(x, window.innerWidth - x),\n Math.max(y, window.innerHeight - y)\n );\n\n const transition = (document as any).startViewTransition(() => {\n updateThemeCallback();\n });\n\n transition.ready\n ?.then(() => {\n document.documentElement.animate(\n {\n clipPath: [\n `circle(0px at ${x}px ${y}px)`,\n `circle(${endRadius}px at ${x}px ${y}px)`,\n ],\n },\n {\n duration: 500,\n easing: \"ease-in-out\",\n pseudoElement: \"::view-transition-new(root)\",\n }\n );\n })\n ?.catch(() => {\n // Graceful fallback\n });\n return;\n }\n\n // Smooth opacity fade view transition\n if (effect === \"fade\") {\n const transition = (document as any).startViewTransition(() => {\n updateThemeCallback();\n });\n\n transition.ready\n ?.then(() => {\n document.documentElement.animate(\n {\n opacity: [0, 1],\n },\n {\n duration: 350,\n easing: \"ease-in-out\",\n pseudoElement: \"::view-transition-new(root)\",\n }\n );\n })\n ?.catch(() => {\n // Graceful fallback\n });\n return;\n }\n\n updateThemeCallback();\n } catch {\n updateThemeCallback();\n }\n}",
25
26
  "type": "registry:ui"
26
27
  },
27
28
  {
28
29
  "path": "providers/theme-script.tsx",
29
- "content": "import { type Component } from \"solid-js\";\n\nexport interface ThemeScriptProps {\n /** Storage key namespace used in localStorage (default: \"nikala-theme\") */\n storageKey?: string;\n /** Initial default theme mode if no saved preference exists (default: \"system\") */\n defaultTheme?: \"light\" | \"dark\" | \"system\";\n /** Initial default primary accent color if no saved preference exists */\n defaultAccent?: string;\n /** Initial default border radius if no saved preference exists */\n defaultRadius?: string;\n}\n\n/**\n * Pre-hydration inline script executed synchronously before DOM paint to prevent theme flickering (anti-FOUC).\n */\nexport const ThemeScript: Component<ThemeScriptProps> = (props) => {\n const key = props.storageKey || \"nikala-theme\";\n const defTheme = props.defaultTheme || \"system\";\n const defAccent = props.defaultAccent || \"\";\n const defRadius = props.defaultRadius || \"\";\n\n const scriptText = `(function(){try{\n var key = '${key}';\n var mode = localStorage.getItem(key + '-mode') || '${defTheme}';\n var accent = localStorage.getItem(key + '-accent') || '${defAccent}';\n var radius = localStorage.getItem(key + '-radius') || '${defRadius}';\n\n var isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;\n var resolvedDark = mode === 'dark' || (mode === 'system' && isDark);\n\n var root = document.documentElement;\n root.classList.remove('light', 'dark');\n root.classList.add(resolvedDark ? 'dark' : 'light');\n root.style.colorScheme = resolvedDark ? 'dark' : 'light';\n\n var colorMap = {\n wine: resolvedDark ? '#9e3b47' : '#722f37',\n violet: resolvedDark ? '#8b5cf6' : '#7c3aed',\n sky: resolvedDark ? '#38bdf8' : '#0284c7',\n emerald: resolvedDark ? '#34d399' : '#059669',\n rose: resolvedDark ? '#fb7185' : '#e11d48',\n amber: resolvedDark ? '#fbbf24' : '#d97706',\n zinc: resolvedDark ? '#fafafa' : '#18181b'\n };\n\n if (accent && colorMap[accent]) {\n root.style.setProperty('--primary', colorMap[accent]);\n }\n\n if (radius) {\n var radVal = radius.endsWith('rem') ? radius : radius + 'rem';\n root.style.setProperty('--radius', radVal);\n }\n}catch(e){}})();`;\n\n return <script innerHTML={scriptText} />;\n};",
30
+ "content": "import { isServer } from \"solid-js/web\";\nimport type { Component } from \"solid-js\";\n\nexport interface ThemeScriptProps {\n /** Storage key namespace used in localStorage (default: \"nikala-theme\") */\n storageKey?: string;\n /** Initial default theme mode if no saved preference exists (default: \"system\") */\n defaultTheme?: \"light\" | \"dark\" | \"system\";\n /** Initial default primary accent color if no saved preference exists */\n defaultAccent?: string;\n /** Initial default border radius if no saved preference exists */\n defaultRadius?: string;\n}\n\n/**\n * Pre-hydration inline script executed synchronously before DOM paint to prevent theme flickering (anti-FOUC).\n */\nexport const ThemeScript: Component<ThemeScriptProps> = (props) => {\n if (!isServer) return null;\n const key = props.storageKey || \"nikala-theme\";\n const defTheme = props.defaultTheme || \"system\";\n const defAccent = props.defaultAccent || \"\";\n const defRadius = props.defaultRadius || \"\";\n\n const scriptText = `(function(){try{\n var key = '${key}';\n var mode = localStorage.getItem(key + '-mode') || '${defTheme}';\n var accent = localStorage.getItem(key + '-accent') || '${defAccent}';\n var radius = localStorage.getItem(key + '-radius') || '${defRadius}';\n\n var isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;\n var resolvedDark = mode === 'dark' || (mode === 'system' && isDark);\n\n var root = document.documentElement;\n root.classList.remove('light', 'dark');\n root.classList.add(resolvedDark ? 'dark' : 'light');\n root.style.colorScheme = resolvedDark ? 'dark' : 'light';\n\n var colorMap = {\n yellow: {\n primary: resolvedDark ? 'oklch(0.852 0.199 91.936)' : 'oklch(0.795 0.184 86.047)',\n foreground: 'oklch(0.145 0 0)'\n },\n red: {\n primary: resolvedDark ? 'oklch(0.637 0.237 25.331)' : 'oklch(0.577 0.245 27.325)',\n foreground: 'oklch(0.985 0 0)'\n },\n violet: {\n primary: resolvedDark ? 'oklch(0.606 0.25 292.717)' : 'oklch(0.541 0.281 293.009)',\n foreground: 'oklch(0.985 0 0)'\n },\n sky: {\n primary: resolvedDark ? 'oklch(0.672 0.154 238.29)' : 'oklch(0.588 0.158 241.966)',\n foreground: resolvedDark ? 'oklch(0.145 0 0)' : 'oklch(0.985 0 0)'\n },\n emerald: {\n primary: resolvedDark ? 'oklch(0.696 0.17 162.48)' : 'oklch(0.596 0.145 163.225)',\n foreground: resolvedDark ? 'oklch(0.145 0 0)' : 'oklch(0.985 0 0)'\n },\n zinc: {\n primary: resolvedDark ? 'oklch(0.985 0 0)' : 'oklch(0.205 0 0)',\n foreground: resolvedDark ? 'oklch(0.205 0 0)' : 'oklch(0.985 0 0)'\n }\n };\n\n if (accent && colorMap[accent]) {\n root.style.setProperty('--primary', colorMap[accent].primary);\n root.style.setProperty('--primary-foreground', colorMap[accent].foreground);\n }\n\n if (radius) {\n var radVal = radius.endsWith('rem') ? radius : radius + 'rem';\n root.style.setProperty('--radius', radVal);\n }\n}catch(e){}})();`;\n\n return <script innerHTML={scriptText} />;\n};",
30
31
  "type": "registry:ui"
31
32
  },
32
33
  {
33
34
  "path": "ui/theme-toggle.tsx",
34
- "content": "import { splitProps, type Component, For, Show } from \"solid-js\";\nimport { Sun, Moon, Monitor } from \"lucide-solid\";\nimport {\n useTheme,\n type AccentColor,\n type Radius,\n type Theme,\n} from \"../../providers/theme-provider\";\nimport {\n runThemeTransition,\n type ThemeEffect,\n} from \"../../providers/theme-transitions\";\nimport { Button } from \"./button\";\nimport {\n DropdownMenu,\n DropdownMenuTrigger,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuLabel,\n DropdownMenuSeparator,\n} from \"./dropdown-menu\";\nimport { cn } from \"@/lib/cn\";\n\nimport { createColorMode } from \"@nikala-ui/hooks\";\n\nexport interface ThemeToggleProps {\n /** Display mode: \"mini\" for compact dropdown, \"max\" for full customizer panel (default: \"mini\") */\n mode?: \"mini\" | \"max\";\n /** Transition animation effect when changing themes (\"none\", \"circular\", \"fade\") */\n effect?: ThemeEffect;\n class?: string;\n}\n\nconst ACCENT_OPTIONS: { name: AccentColor; label: string; color: string }[] = [\n { name: \"wine\", label: \"Wine\", color: \"bg-[#722f37]\" },\n { name: \"violet\", label: \"Violet\", color: \"bg-[#7c3aed]\" },\n { name: \"sky\", label: \"Sky\", color: \"bg-[#0284c7]\" },\n { name: \"emerald\", label: \"Emerald\", color: \"bg-[#059669]\" },\n { name: \"rose\", label: \"Rose\", color: \"bg-[#e11d48]\" },\n { name: \"amber\", label: \"Amber\", color: \"bg-[#d97706]\" },\n { name: \"zinc\", label: \"Zinc\", color: \"bg-[#18181b]\" },\n];\n\nconst RADIUS_OPTIONS: { value: Radius; label: string }[] = [\n { value: \"0\", label: \"0\" },\n { value: \"0.3\", label: \"0.3\" },\n { value: \"0.5\", label: \"0.5\" },\n { value: \"0.75\", label: \"0.75\" },\n { value: \"1.0\", label: \"1.0\" },\n];\n\n/**\n * Interactive UI theme switcher supporting mini/max modes and View Transition animations.\n */\nexport const ThemeToggle: Component<ThemeToggleProps> = (props) => {\n const [local] = splitProps(props, [\"mode\", \"effect\", \"class\"]);\n const { theme, setTheme, accent, setAccent, radius, setRadius } = useTheme();\n\n const colorMode = createColorMode({\n initialValue: theme(),\n storageKey: \"nikala-theme-mode\",\n });\n\n const mode = () => local.mode || \"mini\";\n const effect = () => local.effect || \"none\";\n\n /* Reactive accessor determining whether dark mode is active via createColorMode hook */\n const isDarkMode = () => {\n const currentTheme = theme();\n if (currentTheme === \"dark\") return true;\n if (currentTheme === \"light\") return false;\n return colorMode.isDark();\n };\n\n const changeThemeWithEffect = (newTheme: Theme, e: MouseEvent) => {\n runThemeTransition(effect(), e, () => {\n setTheme(newTheme);\n colorMode.setMode(newTheme);\n });\n };\n\n return (\n <DropdownMenu placement=\"bottom-end\">\n <DropdownMenuTrigger\n as={Button}\n variant=\"ghost\"\n size=\"icon\"\n class={cn(\"relative h-9 w-9 cursor-pointer\", local.class)}\n >\n {/* Reactive Sun / Moon Icon Toggle */}\n <Show\n when={isDarkMode()}\n fallback={<Sun class=\"h-4 w-4 text-foreground transition-transform\" />}\n >\n <Moon class=\"h-4 w-4 text-foreground transition-transform\" />\n </Show>\n\n <span class=\"sr-only\">Toggle theme</span>\n </DropdownMenuTrigger>\n\n <Show\n when={mode() === \"max\"}\n fallback={\n /* Mini Mode: Compact Dropdown */\n <DropdownMenuContent>\n <DropdownMenuItem onClick={(e: MouseEvent) => changeThemeWithEffect(\"light\", e)}>\n <Sun class=\"mr-2 h-4 w-4 text-muted-foreground\" />\n Light\n </DropdownMenuItem>\n\n <DropdownMenuItem onClick={(e: MouseEvent) => changeThemeWithEffect(\"dark\", e)}>\n <Moon class=\"mr-2 h-4 w-4 text-muted-foreground\" />\n Dark\n </DropdownMenuItem>\n\n <DropdownMenuItem onClick={(e: MouseEvent) => changeThemeWithEffect(\"system\", e)}>\n <Monitor class=\"mr-2 h-4 w-4 text-muted-foreground\" />\n System\n </DropdownMenuItem>\n </DropdownMenuContent>\n }\n >\n {/* Max Mode: Full Theme Customizer Panel */}\n <DropdownMenuContent class=\"w-64\">\n <div class=\"p-2 space-y-2\">\n <DropdownMenuLabel class=\"px-0 pt-0 text-xs font-semibold uppercase tracking-wider text-muted-foreground\">\n Theme Mode\n </DropdownMenuLabel>\n <div class=\"grid grid-cols-3 gap-1 my-1.5\">\n <Button\n variant={theme() === \"light\" ? \"default\" : \"outline\"}\n size=\"sm\"\n onClick={(e: MouseEvent) => changeThemeWithEffect(\"light\", e)}\n class=\"h-8 text-xs cursor-pointer\"\n >\n Light\n </Button>\n <Button\n variant={theme() === \"dark\" ? \"default\" : \"outline\"}\n size=\"sm\"\n onClick={(e: MouseEvent) => changeThemeWithEffect(\"dark\", e)}\n class=\"h-8 text-xs cursor-pointer\"\n >\n Dark\n </Button>\n <Button\n variant={theme() === \"system\" ? \"default\" : \"outline\"}\n size=\"sm\"\n onClick={(e: MouseEvent) => changeThemeWithEffect(\"system\", e)}\n class=\"h-8 text-xs cursor-pointer\"\n >\n System\n </Button>\n </div>\n\n <DropdownMenuSeparator class=\"my-2\" />\n\n <DropdownMenuLabel class=\"px-0 text-xs font-semibold uppercase tracking-wider text-muted-foreground\">\n Brand Accent Color\n </DropdownMenuLabel>\n <div class=\"flex flex-wrap gap-1.5 my-1.5\">\n <For each={ACCENT_OPTIONS}>\n {(opt) => (\n <button\n type=\"button\"\n title={opt.label}\n onClick={() => setAccent(opt.name)}\n class={cn(\n \"h-6 w-6 rounded-md transition-all cursor-pointer border border-border flex items-center justify-center\",\n opt.color,\n accent() === opt.name ? \"ring-2 ring-primary ring-offset-2 ring-offset-background scale-110\" : \"hover:scale-105\"\n )}\n />\n )}\n </For>\n </div>\n\n <DropdownMenuSeparator class=\"my-2\" />\n\n <DropdownMenuLabel class=\"px-0 text-xs font-semibold uppercase tracking-wider text-muted-foreground\">\n Border Radius\n </DropdownMenuLabel>\n <div class=\"grid grid-cols-5 gap-1 my-1.5\">\n <For each={RADIUS_OPTIONS}>\n {(r) => (\n <Button\n variant={radius() === r.value ? \"default\" : \"outline\"}\n size=\"sm\"\n onClick={() => setRadius(r.value)}\n class=\"h-7 text-xs px-1 cursor-pointer\"\n >\n {r.label}\n </Button>\n )}\n </For>\n </div>\n </div>\n </DropdownMenuContent>\n </Show>\n </DropdownMenu>\n );\n};",
35
+ "content": "import { splitProps, type Component, For, Show, type JSX } from \"solid-js\";\nimport { Sun, Moon, Monitor } from \"lucide-solid\";\nimport {\n useTheme,\n type AccentColor,\n type Radius,\n type Theme,\n} from \"../../providers/theme-provider\";\nimport {\n runThemeTransition,\n type ThemeEffect,\n} from \"../../providers/theme-transitions\";\nimport { Button, type ButtonProps } from \"./button\";\nimport {\n DropdownMenu,\n DropdownMenuTrigger,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuGroup,\n DropdownMenuLabel,\n DropdownMenuSeparator,\n} from \"./dropdown-menu\";\nimport { cn } from \"@/lib/cn\";\n\nexport interface ThemeToggleProps\n extends Omit<JSX.ButtonHTMLAttributes<HTMLButtonElement>, \"onClick\"> {\n /** Display mode: \"button\" (direct 1-click toggle), \"mini\" (compact dropdown), \"max\" (full customizer panel). Defaults to \"mini\". */\n mode?: \"button\" | \"mini\" | \"max\";\n /** Transition animation effect when changing themes (\"none\", \"circular\", \"fade\") */\n effect?: ThemeEffect;\n /** Button visual style variant when rendered as a button or dropdown trigger */\n variant?: ButtonProps[\"variant\"];\n /** Button size */\n size?: ButtonProps[\"size\"];\n class?: string;\n}\n\nconst ACCENT_OPTIONS: { name: AccentColor; label: string; color: string }[] = [\n { name: \"yellow\", label: \"Yellow\", color: \"bg-yellow-500\" },\n { name: \"red\", label: \"Red\", color: \"bg-red-500\" },\n { name: \"violet\", label: \"Violet\", color: \"bg-violet-500\" },\n { name: \"sky\", label: \"Sky\", color: \"bg-sky-500\" },\n { name: \"emerald\", label: \"Emerald\", color: \"bg-emerald-500\" },\n { name: \"zinc\", label: \"Zinc\", color: \"bg-zinc-800 dark:bg-zinc-200\" },\n];\n\nconst RADIUS_OPTIONS: { value: Radius; label: string }[] = [\n { value: \"0\", label: \"0\" },\n { value: \"0.3\", label: \"0.3\" },\n { value: \"0.5\", label: \"0.5\" },\n { value: \"0.75\", label: \"0.75\" },\n { value: \"1.0\", label: \"1.0\" },\n];\n\n/**\n * Interactive UI theme switcher supporting button, mini (dropdown), and max (customizer panel) modes with View Transition animations.\n */\nexport const ThemeToggle: Component<ThemeToggleProps> = (props) => {\n const [local, rest] = splitProps(props, [\"mode\", \"effect\", \"variant\", \"size\", \"class\"]);\n const { theme, setTheme, accent, setAccent, radius, setRadius } = useTheme();\n\n const mode = () => local.mode || \"mini\";\n const effect = () => local.effect || \"none\";\n\n /* Reactive accessor determining whether dark mode is active */\n const isDarkMode = () => {\n const currentTheme = theme();\n if (currentTheme === \"dark\") return true;\n if (currentTheme === \"light\") return false;\n if (typeof window !== \"undefined\") {\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches;\n }\n return false;\n };\n\n const changeThemeWithEffect = (newTheme: Theme, e: MouseEvent) => {\n runThemeTransition(effect(), e, () => {\n setTheme(newTheme);\n });\n };\n\n const handleToggleClick = (e: MouseEvent) => {\n const nextTheme: Theme = isDarkMode() ? \"light\" : \"dark\";\n changeThemeWithEffect(nextTheme, e);\n };\n\n return (\n <Show\n when={mode() !== \"button\"}\n fallback={\n /* Button Mode: Direct 1-click Dark/Light switcher without dropdown */\n <Button\n variant={local.variant || \"ghost\"}\n size={local.size || \"icon\"}\n onClick={handleToggleClick}\n class={cn(\"relative cursor-pointer\", local.class)}\n aria-label=\"Toggle theme\"\n {...rest}\n >\n <Show\n when={isDarkMode()}\n fallback={<Sun class=\"h-4 w-4 text-foreground transition-transform\" />}\n >\n <Moon class=\"h-4 w-4 text-foreground transition-transform\" />\n </Show>\n <span class=\"sr-only\">Toggle theme</span>\n </Button>\n }\n >\n <DropdownMenu placement=\"bottom-end\">\n <DropdownMenuTrigger\n as={Button}\n variant={local.variant || \"ghost\"}\n size={local.size || \"icon\"}\n class={cn(\"relative cursor-pointer\", local.class)}\n {...rest}\n >\n {/* Reactive Sun / Moon Icon Toggle */}\n <Show\n when={isDarkMode()}\n fallback={<Sun class=\"h-4 w-4 text-foreground transition-transform\" />}\n >\n <Moon class=\"h-4 w-4 text-foreground transition-transform\" />\n </Show>\n <span class=\"sr-only\">Toggle theme</span>\n </DropdownMenuTrigger>\n\n <Show\n when={mode() === \"max\"}\n fallback={\n /* Mini Mode: Compact Dropdown */\n <DropdownMenuContent>\n <DropdownMenuItem onClick={(e: MouseEvent) => changeThemeWithEffect(\"light\", e)}>\n <Sun class=\"mr-2 h-4 w-4 text-muted-foreground\" />\n Light\n </DropdownMenuItem>\n\n <DropdownMenuItem onClick={(e: MouseEvent) => changeThemeWithEffect(\"dark\", e)}>\n <Moon class=\"mr-2 h-4 w-4 text-muted-foreground\" />\n Dark\n </DropdownMenuItem>\n\n <DropdownMenuItem onClick={(e: MouseEvent) => changeThemeWithEffect(\"system\", e)}>\n <Monitor class=\"mr-2 h-4 w-4 text-muted-foreground\" />\n System\n </DropdownMenuItem>\n </DropdownMenuContent>\n }\n >\n {/* Max Mode: Full Theme Customizer Panel */}\n <DropdownMenuContent class=\"w-64\">\n <div class=\"p-2 space-y-2\">\n <DropdownMenuGroup>\n <DropdownMenuLabel class=\"px-0 pt-0 text-xs font-semibold uppercase tracking-wider text-muted-foreground\">\n Theme Mode\n </DropdownMenuLabel>\n <div class=\"grid grid-cols-3 gap-1 my-1.5\">\n <Button\n variant={theme() === \"light\" ? \"default\" : \"outline\"}\n size=\"sm\"\n onClick={(e: MouseEvent) => changeThemeWithEffect(\"light\", e)}\n class=\"h-8 text-xs cursor-pointer\"\n >\n Light\n </Button>\n <Button\n variant={theme() === \"dark\" ? \"default\" : \"outline\"}\n size=\"sm\"\n onClick={(e: MouseEvent) => changeThemeWithEffect(\"dark\", e)}\n class=\"h-8 text-xs cursor-pointer\"\n >\n Dark\n </Button>\n <Button\n variant={theme() === \"system\" ? \"default\" : \"outline\"}\n size=\"sm\"\n onClick={(e: MouseEvent) => changeThemeWithEffect(\"system\", e)}\n class=\"h-8 text-xs cursor-pointer\"\n >\n System\n </Button>\n </div>\n </DropdownMenuGroup>\n\n <DropdownMenuSeparator class=\"my-2\" />\n\n <DropdownMenuGroup>\n <DropdownMenuLabel class=\"px-0 text-xs font-semibold uppercase tracking-wider text-muted-foreground\">\n Brand Accent Color\n </DropdownMenuLabel>\n <div class=\"flex flex-wrap gap-1.5 my-1.5\">\n <For each={ACCENT_OPTIONS}>\n {(opt) => (\n <button\n type=\"button\"\n title={opt.label}\n onClick={() => setAccent(opt.name)}\n class={cn(\n \"h-6 w-6 rounded-md transition-all cursor-pointer border border-border flex items-center justify-center\",\n opt.color,\n accent() === opt.name\n ? \"ring-2 ring-primary ring-offset-2 ring-offset-background scale-110\"\n : \"hover:scale-105\"\n )}\n />\n )}\n </For>\n </div>\n </DropdownMenuGroup>\n\n <DropdownMenuSeparator class=\"my-2\" />\n\n <DropdownMenuGroup>\n <DropdownMenuLabel class=\"px-0 text-xs font-semibold uppercase tracking-wider text-muted-foreground\">\n Border Radius\n </DropdownMenuLabel>\n <div class=\"grid grid-cols-5 gap-1 my-1.5\">\n <For each={RADIUS_OPTIONS}>\n {(r) => (\n <Button\n variant={radius() === r.value ? \"default\" : \"outline\"}\n size=\"sm\"\n onClick={() => setRadius(r.value)}\n class=\"h-7 text-xs px-1 cursor-pointer\"\n >\n {r.label}\n </Button>\n )}\n </For>\n </div>\n </DropdownMenuGroup>\n </div>\n </DropdownMenuContent>\n </Show>\n </DropdownMenu>\n </Show>\n );\n};",
35
36
  "type": "registry:ui"
36
37
  }
37
38
  ]
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "titlebar-tabs",
3
+ "title": "Titlebar Tabs",
4
+ "description": "Native draggable tab bar integrated directly inside desktop titlebars for multi-document applications.",
5
+ "type": "registry:ui",
6
+ "dependencies": [
7
+ "clsx",
8
+ "tailwind-merge",
9
+ "class-variance-authority",
10
+ "lucide-solid"
11
+ ],
12
+ "registryDependencies": [
13
+ "button",
14
+ "tooltip",
15
+ "create-document-tabs"
16
+ ],
17
+ "files": [
18
+ {
19
+ "path": "ui/titlebar-tabs.tsx",
20
+ "content": "import {\n createContext,\n useContext,\n splitProps,\n createSignal,\n createEffect,\n onMount,\n onCleanup,\n Show,\n For,\n type Component,\n type JSX,\n type ParentComponent,\n type Accessor,\n} from \"solid-js\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport { Plus, X, Pin, XCircle, CircleX } from \"lucide-solid\";\nimport * as ContextMenuPrimitive from \"@kobalte/core/context-menu\";\nimport { Button } from \"@/components/ui/button\";\nimport { Tooltip, TooltipTrigger, TooltipContent } from \"@/components/ui/tooltip\";\nimport {\n type CreateDocumentTabsReturn,\n type TabItem,\n} from \"@/hooks/create-document-tabs\";\nimport { cn } from \"@/lib/cn\";\n\n/* --- 1. Context & Variants --- */\n\nexport type TitlebarTabsVariant = \"editor\" | \"chrome\" | \"pills\";\n\ninterface TitlebarTabsContextValue {\n value: Accessor<string | undefined>;\n setValue: (value: string) => void;\n variant: Accessor<TitlebarTabsVariant>;\n manager?: CreateDocumentTabsReturn<any>;\n reorderable?: boolean;\n enableContextMenu?: boolean;\n draggedTabId: Accessor<string | null>;\n dropTargetId: Accessor<string | null>;\n dropPosition: Accessor<\"left\" | \"right\" | null>;\n setDragState: (draggedId: string | null, targetId: string | null, position: \"left\" | \"right\" | null) => void;\n handleDropReorder: (sourceId: string, targetId: string, position: \"left\" | \"right\") => void;\n}\n\nconst TitlebarTabsContext = createContext<TitlebarTabsContextValue>();\n\nexport function useTitlebarTabs() {\n const ctx = useContext(TitlebarTabsContext);\n if (!ctx) {\n throw new Error(\"useTitlebarTabs must be used within a <TitlebarTabs /> container\");\n }\n return ctx;\n}\n\nexport const titlebarTabVariants = cva(\n \"group relative inline-flex items-center gap-1.5 px-3 text-xs font-medium select-none cursor-pointer transition-colors shrink-0 max-w-[180px] min-w-[80px]\",\n {\n variants: {\n variant: {\n editor:\n \"h-full py-1 border-r border-border/40 border-t-2 border-t-transparent hover:bg-muted/40 data-[active=true]:bg-background data-[active=true]:border-t-primary data-[active=true]:text-foreground data-[active=false]:text-muted-foreground\",\n chrome:\n \"h-[calc(100%-2px)] py-1 rounded-t-lg border-t border-x border-transparent hover:bg-muted/60 data-[active=true]:border-border/70 data-[active=true]:bg-background data-[active=true]:text-foreground data-[active=true]:-mb-px data-[active=true]:shadow-xs data-[active=false]:text-muted-foreground\",\n pills:\n \"h-7 py-0.5 rounded-md mx-0.5 hover:bg-muted/50 data-[active=true]:bg-primary/10 data-[active=true]:text-primary data-[active=true]:font-semibold data-[active=false]:text-muted-foreground\",\n },\n },\n defaultVariants: {\n variant: \"editor\",\n },\n }\n);\n\n/* --- 2. TitlebarTabs Root Container --- */\n\nexport interface TitlebarTabsProps extends JSX.HTMLAttributes<HTMLElement> {\n /** Reactive tabs manager instance from createDocumentTabs(). */\n manager?: CreateDocumentTabsReturn<any>;\n /** Controlled active tab identifier value (when not using manager). */\n value?: string;\n /** Uncontrolled default active tab identifier value. */\n defaultValue?: string;\n /** Callback triggered when active tab changes. */\n onValueChange?: (value: string) => void;\n /** Callback triggered when the \"+\" add tab button is clicked. */\n onAddTab?: () => void;\n /** Whether tabs can be reordered via Drag & Drop. Defaults to true when manager is present. */\n reorderable?: boolean;\n /** Whether right-click context menu is enabled on tabs and empty space. Defaults to true. */\n enableContextMenu?: boolean;\n /** Custom render function for the empty tabstrip context menu. */\n renderEmptyContextMenu?: () => JSX.Element;\n /** Visual chrome styling variant. */\n variant?: TitlebarTabsVariant;\n class?: string;\n}\n\nexport const TitlebarTabs: ParentComponent<TitlebarTabsProps> = (props) => {\n const [local, rest] = splitProps(props, [\n \"manager\",\n \"value\",\n \"defaultValue\",\n \"onValueChange\",\n \"onAddTab\",\n \"reorderable\",\n \"enableContextMenu\",\n \"renderEmptyContextMenu\",\n \"variant\",\n \"class\",\n \"children\",\n ]);\n\n let scrollContainerRef: HTMLDivElement | undefined;\n\n const [internalValue, setInternalValue] = createSignal(local.defaultValue || local.value);\n const [draggedTabId, setDraggedTabId] = createSignal<string | null>(null);\n const [dropTargetId, setDropTargetId] = createSignal<string | null>(null);\n const [dropPosition, setDropPosition] = createSignal<\"left\" | \"right\" | null>(null);\n\n const activeValue = () => {\n if (local.manager) return local.manager.activeTabId();\n return local.value !== undefined ? local.value : internalValue();\n };\n\n const variant = () => local.variant || \"editor\";\n\n const setValue = (val: string) => {\n if (local.manager) {\n local.manager.setActiveTab(val);\n } else {\n setInternalValue(val);\n local.onValueChange?.(val);\n }\n };\n\n const setDragState = (\n draggedId: string | null,\n targetId: string | null,\n pos: \"left\" | \"right\" | null\n ) => {\n setDraggedTabId(draggedId);\n setDropTargetId(targetId);\n setDropPosition(pos);\n };\n\n const handleDropReorder = (sourceId: string, targetId: string, pos: \"left\" | \"right\") => {\n if (!local.manager) return;\n const list = local.manager.tabs();\n const fromIndex = list.findIndex((t) => t.id === sourceId);\n const targetIndex = list.findIndex((t) => t.id === targetId);\n if (fromIndex === -1 || targetIndex === -1 || fromIndex === targetIndex) return;\n\n let finalIndex = targetIndex;\n if (fromIndex < targetIndex && pos === \"left\") {\n finalIndex = targetIndex - 1;\n } else if (fromIndex > targetIndex && pos === \"right\") {\n finalIndex = targetIndex + 1;\n }\n\n if (fromIndex !== finalIndex && finalIndex >= 0 && finalIndex < list.length) {\n local.manager.reorderTabs(fromIndex, finalIndex);\n }\n };\n\n // Mouse wheel horizontal scroll listener (converts vertical wheel deltaY to horizontal scroll)\n onMount(() => {\n if (!scrollContainerRef) return;\n\n const handleWheel = (e: WheelEvent) => {\n if (e.deltaY !== 0) {\n e.preventDefault();\n scrollContainerRef!.scrollLeft += e.deltaY;\n }\n };\n\n scrollContainerRef.addEventListener(\"wheel\", handleWheel, { passive: false });\n onCleanup(() => {\n scrollContainerRef?.removeEventListener(\"wheel\", handleWheel);\n });\n });\n\n // Auto-scroll to end when tabs are added\n createEffect(() => {\n if (local.manager?.count() && scrollContainerRef) {\n scrollContainerRef.scrollTo({\n left: scrollContainerRef.scrollWidth,\n behavior: \"smooth\",\n });\n }\n });\n\n const contextValue: TitlebarTabsContextValue = {\n value: activeValue,\n setValue,\n variant,\n manager: local.manager,\n reorderable: local.reorderable ?? Boolean(local.manager),\n enableContextMenu: local.enableContextMenu ?? true,\n draggedTabId,\n dropTargetId,\n dropPosition,\n setDragState,\n handleDropReorder,\n };\n\n const hasEmptyContextMenu = () =>\n (local.enableContextMenu ?? true) &&\n (Boolean(local.onAddTab) || Boolean(local.manager) || Boolean(local.renderEmptyContextMenu));\n\n const renderNavContent = () => (\n <nav\n aria-label=\"Window Tabs\"\n class={cn(\n \"flex flex-1 h-full overflow-hidden z-10 min-w-0 pointer-events-auto [app-region:drag] [-webkit-app-region:drag]\",\n variant() === \"chrome\" ? \"items-end\" : \"items-center\",\n local.class\n )}\n {...rest}\n >\n <Show\n when={local.children}\n fallback={\n /* Automatic Declarative Mode: Tab List with Mouse Wheel Scroll + Anchored Add Button */\n <>\n <div\n ref={scrollContainerRef}\n class={cn(\n \"flex h-full overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] gap-0 items-center min-w-0 flex-1 scroll-smooth [app-region:drag] [-webkit-app-region:drag]\",\n variant() === \"chrome\" && \"items-end pt-1.5\"\n )}\n >\n <Show when={local.manager}>\n <For each={local.manager!.tabs()}>\n {(tab) => <TitlebarTab tab={tab} />}\n </For>\n </Show>\n </div>\n\n {/* Anchored Add Tab Button (Always visible outside scroll container) */}\n <Show when={local.onAddTab}>\n <div class={cn(\"shrink-0 px-1 z-20 flex items-center\", variant() === \"chrome\" ? \"mb-1\" : \"my-auto\")}>\n <TitlebarTabAddButton onClick={local.onAddTab} />\n </div>\n </Show>\n </>\n }\n >\n {local.children}\n </Show>\n </nav>\n );\n\n return (\n <TitlebarTabsContext.Provider value={contextValue}>\n <Show when={hasEmptyContextMenu()} fallback={renderNavContent()}>\n <ContextMenuPrimitive.Root>\n <ContextMenuPrimitive.Trigger\n as=\"div\"\n style={(\"-webkit-app-region: no-drag; app-region: no-drag; outline: none !important; -webkit-focus-ring-color: transparent !important;\") as any}\n class=\"flex flex-1 h-full min-w-0 outline-none ring-0 focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0\"\n >\n {renderNavContent()}\n </ContextMenuPrimitive.Trigger>\n <ContextMenuPrimitive.Portal>\n <ContextMenuPrimitive.Content\n data-no-drag\n data-tauri-drag-region=\"false\"\n style=\"-webkit-app-region: no-drag; app-region: no-drag; outline: none !important; -webkit-focus-ring-color: transparent !important;\"\n onMouseDown={(e) => e.stopPropagation()}\n onPointerDown={(e) => e.stopPropagation()}\n class={cn(\n \"z-50 min-w-[160px] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md\",\n \"animate-in fade-in-80 slide-in-from-top-1 text-xs pointer-events-auto [app-region:no-drag] [-webkit-app-region:no-drag]\",\n \"outline-none ring-0 focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0\"\n )}\n >\n <Show\n when={local.renderEmptyContextMenu}\n fallback={\n <>\n <Show when={local.onAddTab}>\n <ContextMenuPrimitive.Item\n data-no-drag\n data-tauri-drag-region=\"false\"\n style=\"-webkit-app-region: no-drag; app-region: no-drag;\"\n onMouseDown={(e) => e.stopPropagation()}\n onPointerDown={(e) => e.stopPropagation()}\n onSelect={() => {\n setTimeout(() => local.onAddTab?.(), 0);\n }}\n class=\"relative flex cursor-pointer select-none items-center justify-between gap-2 rounded-xs px-2 py-1.5 text-xs outline-none transition-colors hover:bg-accent hover:text-accent-foreground pointer-events-auto [app-region:no-drag] [-webkit-app-region:no-drag]\"\n >\n <span class=\"flex items-center gap-2\">\n <Plus class=\"size-3.5 text-muted-foreground\" />\n New Tab\n </span>\n <span class=\"ml-auto text-[10px] text-muted-foreground font-mono\">Ctrl+T</span>\n </ContextMenuPrimitive.Item>\n </Show>\n\n <Show when={local.manager && local.manager.tabs().length > 0}>\n <Show when={local.onAddTab}>\n <ContextMenuPrimitive.Separator class=\"my-1 h-px bg-border/60\" />\n </Show>\n <ContextMenuPrimitive.Item\n data-no-drag\n data-tauri-drag-region=\"false\"\n style=\"-webkit-app-region: no-drag; app-region: no-drag;\"\n onMouseDown={(e) => e.stopPropagation()}\n onPointerDown={(e) => e.stopPropagation()}\n onSelect={() => {\n setTimeout(() => local.manager?.closeAll(), 0);\n }}\n class=\"relative flex cursor-pointer select-none items-center gap-2 rounded-xs px-2 py-1.5 text-xs outline-none transition-colors hover:bg-accent hover:text-accent-foreground pointer-events-auto [app-region:no-drag] [-webkit-app-region:no-drag]\"\n >\n <CircleX class=\"size-3.5 text-muted-foreground\" />\n <span>Close All Tabs</span>\n </ContextMenuPrimitive.Item>\n </Show>\n </>\n }\n >\n {local.renderEmptyContextMenu!()}\n </Show>\n </ContextMenuPrimitive.Content>\n </ContextMenuPrimitive.Portal>\n </ContextMenuPrimitive.Root>\n </Show>\n </TitlebarTabsContext.Provider>\n );\n};\n\n/* --- 3. TitlebarTabList --- */\n\nexport interface TitlebarTabListProps extends JSX.HTMLAttributes<HTMLDivElement> {\n class?: string;\n}\n\nexport const TitlebarTabList: ParentComponent<TitlebarTabListProps> = (props) => {\n const [local, rest] = splitProps(props, [\"class\", \"children\"]);\n const ctx = useTitlebarTabs();\n let listRef: HTMLDivElement | undefined;\n\n onMount(() => {\n if (!listRef) return;\n const handleWheel = (e: WheelEvent) => {\n if (e.deltaY !== 0) {\n e.preventDefault();\n listRef!.scrollLeft += e.deltaY;\n }\n };\n listRef.addEventListener(\"wheel\", handleWheel, { passive: false });\n onCleanup(() => listRef?.removeEventListener(\"wheel\", handleWheel));\n });\n\n return (\n <div\n ref={listRef}\n class={cn(\n \"flex h-full overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] gap-0 items-center min-w-0 flex-1 scroll-smooth [app-region:drag] [-webkit-app-region:drag]\",\n ctx.variant() === \"chrome\" && \"items-end pt-1.5\",\n local.class\n )}\n {...rest}\n >\n {local.children}\n </div>\n );\n};\n\n/* --- 4. TitlebarTab Individual Item --- */\n\nexport interface TitlebarTabProps extends Omit<JSX.HTMLAttributes<HTMLDivElement>, \"onClose\"> {\n /** Tab data object from createDocumentTabs(). Automatically wires all properties. */\n tab?: TabItem<any>;\n value?: string;\n isDirty?: boolean;\n isPinned?: boolean;\n closable?: boolean;\n reorderable?: boolean;\n /** Whether right-click context menu is enabled on this tab. Defaults to true. */\n enableContextMenu?: boolean;\n /** Custom render function for context menu content. */\n renderContextMenu?: (tab: TabItem<any>) => JSX.Element;\n onClose?: (e: MouseEvent) => void;\n class?: string;\n}\n\nexport const TitlebarTab: ParentComponent<TitlebarTabProps> = (props) => {\n const [local, rest] = splitProps(props, [\n \"tab\",\n \"value\",\n \"isDirty\",\n \"isPinned\",\n \"closable\",\n \"reorderable\",\n \"enableContextMenu\",\n \"renderContextMenu\",\n \"onClose\",\n \"class\",\n \"style\",\n \"children\",\n \"onClick\",\n \"onKeyDown\",\n \"onDragStart\",\n \"onDragOver\",\n \"onDragLeave\",\n \"onDragEnd\",\n \"onDrop\",\n ]);\n\n const ctx = useTitlebarTabs();\n let tabRef: HTMLDivElement | undefined;\n\n const tabId = () => local.tab?.id || local.value || \"\";\n const isPinned = () => local.tab?.isPinned ?? local.isPinned ?? false;\n const isDirty = () => local.tab?.isDirty ?? local.isDirty ?? false;\n const closable = () => local.tab?.closable ?? local.closable ?? true;\n const title = () => local.tab?.title || \"\";\n const IconComp = () => local.tab?.icon;\n\n const isActive = () => ctx.value() === tabId();\n const isDragging = () => ctx.draggedTabId() === tabId();\n const isDropTarget = () => ctx.dropTargetId() === tabId() && ctx.draggedTabId() !== tabId();\n const dropPosition = () => (isDropTarget() ? ctx.dropPosition() : null);\n\n const canDrag = () => !isPinned() && (local.reorderable ?? ctx.reorderable ?? true);\n const hasContextMenu = () =>\n (local.enableContextMenu ?? ctx.enableContextMenu ?? true) &&\n (Boolean(ctx.manager) || Boolean(local.renderContextMenu));\n\n // Scroll active tab horizontally inside its tablist container\n createEffect(() => {\n if (typeof window !== \"undefined\" && isActive() && tabRef && tabRef.parentElement) {\n try {\n const container = tabRef.parentElement;\n const tabLeft = tabRef.offsetLeft;\n const tabRight = tabLeft + tabRef.offsetWidth;\n const containerLeft = container.scrollLeft;\n const containerRight = containerLeft + container.clientWidth;\n\n if (tabLeft < containerLeft) {\n container.scrollTo({ left: tabLeft, behavior: \"smooth\" });\n } else if (tabRight > containerRight) {\n container.scrollTo({ left: tabRight - container.clientWidth, behavior: \"smooth\" });\n }\n } catch {\n // Safe fallback\n }\n }\n });\n\n const handleClick = (e: MouseEvent) => {\n ctx.setValue(tabId());\n if (typeof local.onClick === \"function\") {\n (local.onClick as any)(e);\n }\n };\n\n const handleKeyDown = (e: KeyboardEvent) => {\n if (e.key === \"Enter\" || e.key === \" \") {\n e.preventDefault();\n ctx.setValue(tabId());\n }\n if (typeof (local as any).onKeyDown === \"function\") {\n ((local as any).onKeyDown)(e);\n }\n };\n\n const handleClose = (e: MouseEvent) => {\n e.stopPropagation();\n if (local.onClose) {\n local.onClose(e);\n } else if (ctx.manager) {\n ctx.manager.closeTab(tabId());\n }\n };\n\n const handleDragStart: JSX.EventHandlerUnion<HTMLDivElement, DragEvent> = (e) => {\n if (!canDrag()) {\n e.preventDefault();\n return;\n }\n if (e.dataTransfer) {\n e.dataTransfer.setData(\"text/plain\", tabId());\n e.dataTransfer.effectAllowed = \"move\";\n }\n ctx.setDragState(tabId(), null, null);\n if (typeof local.onDragStart === \"function\") {\n (local.onDragStart as any)(e);\n }\n };\n\n const handleDragOver: JSX.EventHandlerUnion<HTMLDivElement, DragEvent> = (e) => {\n const currentDragged = ctx.draggedTabId();\n if (!currentDragged || currentDragged === tabId() || isPinned()) return;\n\n e.preventDefault();\n if (e.dataTransfer) {\n e.dataTransfer.dropEffect = \"move\";\n }\n\n if (tabRef) {\n const rect = tabRef.getBoundingClientRect();\n const midpoint = rect.left + rect.width / 2;\n const pos: \"left\" | \"right\" = e.clientX < midpoint ? \"left\" : \"right\";\n ctx.setDragState(currentDragged, tabId(), pos);\n }\n\n if (typeof local.onDragOver === \"function\") {\n (local.onDragOver as any)(e);\n }\n };\n\n const handleDragLeave: JSX.EventHandlerUnion<HTMLDivElement, DragEvent> = (e) => {\n if (tabRef && !tabRef.contains(e.relatedTarget as Node)) {\n if (ctx.dropTargetId() === tabId()) {\n ctx.setDragState(ctx.draggedTabId(), null, null);\n }\n }\n if (typeof local.onDragLeave === \"function\") {\n (local.onDragLeave as any)(e);\n }\n };\n\n const handleDragEnd: JSX.EventHandlerUnion<HTMLDivElement, DragEvent> = (e) => {\n ctx.setDragState(null, null, null);\n if (typeof local.onDragEnd === \"function\") {\n (local.onDragEnd as any)(e);\n }\n };\n\n const handleDrop: JSX.EventHandlerUnion<HTMLDivElement, DragEvent> = (e) => {\n e.preventDefault();\n const sourceId = e.dataTransfer?.getData(\"text/plain\") || ctx.draggedTabId();\n const pos = ctx.dropPosition();\n if (sourceId && pos && sourceId !== tabId()) {\n ctx.handleDropReorder(sourceId, tabId(), pos);\n }\n ctx.setDragState(null, null, null);\n if (typeof local.onDrop === \"function\") {\n (local.onDrop as any)(e);\n }\n };\n\n const renderTabElement = () => (\n <div\n ref={tabRef}\n role=\"tab\"\n tabindex=\"0\"\n aria-selected={isActive()}\n draggable={canDrag()}\n onDragStart={handleDragStart}\n onDragOver={handleDragOver}\n onDragLeave={handleDragLeave}\n onDragEnd={handleDragEnd}\n onDrop={handleDrop}\n data-no-drag\n data-tauri-drag-region=\"false\"\n style={(\"-webkit-app-region: no-drag; app-region: no-drag;\" + (typeof local.style === \"string\" ? ` ${local.style}` : \"\")) as any}\n data-active={isActive() ? \"true\" : \"false\"}\n data-dragging={isDragging() ? \"true\" : undefined}\n onClick={handleClick}\n onKeyDown={handleKeyDown}\n class={cn(\n titlebarTabVariants({ variant: ctx.variant() }),\n isPinned() && \"min-w-fit px-2 max-w-fit\",\n isDragging() && \"opacity-40 scale-95\",\n dropPosition() === \"left\" &&\n \"before:absolute before:left-0 before:top-1 before:bottom-1 before:w-0.5 before:bg-primary before:z-30 before:rouded-lg\",\n dropPosition() === \"right\" &&\n \"after:absolute after:right-0 after:top-1 after:bottom-1 after:w-0.5 after:bg-primary after:z-30 after:rouded-lg\",\n \"cursor-pointer outline-none ring-0 focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 [app-region:no-drag] [-webkit-app-region:no-drag] pointer-events-auto transition-all duration-150\",\n local.class\n )}\n {...rest}\n >\n {/* Tab Icon & Title Slot */}\n <span class=\"flex items-center gap-1.5 truncate flex-1 pointer-events-none\">\n <Show when={IconComp()}>\n {(() => {\n const Comp = IconComp()!;\n return <Comp class=\"size-3.5 shrink-0 text-primary\" />;\n })()}\n </Show>\n <Show when={local.children} fallback={<span class=\"truncate\">{title()}</span>}>\n {local.children}\n </Show>\n </span>\n\n {/* Pinned Tab Badge */}\n <Show when={isPinned()}>\n <Pin class=\"size-2.5 text-muted-foreground rotate-45 shrink-0\" />\n </Show>\n\n {/* Close Button / Dirty Dot Indicator using Nikala UI Button */}\n <Show when={!isPinned() && closable()}>\n <Tooltip>\n <TooltipTrigger\n as={Button}\n variant=\"ghost\"\n size=\"icon\"\n onClick={handleClose}\n aria-label={`Close ${tabId()} tab`}\n class=\"size-4 p-0 rounded-xs text-muted-foreground hover:bg-muted hover:text-foreground shrink-0 ml-0.5 cursor-pointer outline-none ring-0 focus:ring-0 focus-visible:ring-0\"\n >\n <Show\n when={isDirty()}\n fallback={<X class=\"size-3 opacity-60 hover:opacity-100\" />}\n >\n <span class=\"size-1.5 rouded-lg bg-primary group-hover:hidden\" />\n <X class=\"size-3 hidden group-hover:block\" />\n </Show>\n </TooltipTrigger>\n <TooltipContent class=\"text-[10px] py-1 px-2\">\n Close tab (Ctrl+W)\n </TooltipContent>\n </Tooltip>\n </Show>\n </div>\n );\n\n return (\n <Show when={hasContextMenu()} fallback={renderTabElement()}>\n <ContextMenuPrimitive.Root>\n <ContextMenuPrimitive.Trigger\n as=\"div\"\n data-no-drag\n data-tauri-drag-region=\"false\"\n style={(\"-webkit-app-region: no-drag; app-region: no-drag; outline: none !important; -webkit-focus-ring-color: transparent !important;\" + (typeof local.style === \"string\" ? ` ${local.style}` : \"\")) as any}\n class=\"h-full flex items-center shrink-0 pointer-events-auto [app-region:no-drag] [-webkit-app-region:no-drag] outline-none ring-0 focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0\"\n >\n {renderTabElement()}\n </ContextMenuPrimitive.Trigger>\n <ContextMenuPrimitive.Portal>\n <ContextMenuPrimitive.Content\n data-no-drag\n data-tauri-drag-region=\"false\"\n style=\"-webkit-app-region: no-drag; app-region: no-drag; outline: none !important; -webkit-focus-ring-color: transparent !important;\"\n onMouseDown={(e) => e.stopPropagation()}\n onPointerDown={(e) => e.stopPropagation()}\n class={cn(\n \"z-50 min-w-[170px] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md\",\n \"animate-in fade-in-80 slide-in-from-top-1 text-xs pointer-events-auto [app-region:no-drag] [-webkit-app-region:no-drag]\",\n \"outline-none ring-0 focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0\"\n )}\n >\n <Show\n when={local.renderContextMenu}\n fallback={\n <>\n <Show when={ctx.manager}>\n <ContextMenuPrimitive.Item\n data-no-drag\n data-tauri-drag-region=\"false\"\n style=\"-webkit-app-region: no-drag; app-region: no-drag;\"\n onMouseDown={(e) => e.stopPropagation()}\n onPointerDown={(e) => e.stopPropagation()}\n onSelect={() => {\n setTimeout(() => ctx.manager?.togglePin(tabId()), 0);\n }}\n class=\"relative flex cursor-pointer select-none items-center gap-2 rounded-xs px-2 py-1.5 text-xs outline-none transition-colors hover:bg-accent hover:text-accent-foreground pointer-events-auto [app-region:no-drag] [-webkit-app-region:no-drag]\"\n >\n <Pin class=\"size-3.5 text-muted-foreground\" />\n <span>{isPinned() ? \"Unpin Tab\" : \"Pin Tab\"}</span>\n </ContextMenuPrimitive.Item>\n <ContextMenuPrimitive.Separator class=\"my-1 h-px bg-border/60\" />\n </Show>\n\n <ContextMenuPrimitive.Item\n data-no-drag\n data-tauri-drag-region=\"false\"\n style=\"-webkit-app-region: no-drag; app-region: no-drag;\"\n onMouseDown={(e) => e.stopPropagation()}\n onPointerDown={(e) => e.stopPropagation()}\n disabled={isPinned() || !closable()}\n onSelect={() => {\n setTimeout(() => {\n if (local.onClose) local.onClose(new MouseEvent(\"click\"));\n else ctx.manager?.closeTab(tabId());\n }, 0);\n }}\n class=\"relative flex cursor-pointer select-none items-center justify-between gap-2 rounded-xs px-2 py-1.5 text-xs outline-none transition-colors hover:bg-accent hover:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 pointer-events-auto [app-region:no-drag] [-webkit-app-region:no-drag]\"\n >\n <span class=\"flex items-center gap-2\">\n <X class=\"size-3.5 text-muted-foreground\" />\n Close Tab\n </span>\n <span class=\"ml-auto text-[10px] text-muted-foreground font-mono\">Ctrl+W</span>\n </ContextMenuPrimitive.Item>\n\n <Show when={ctx.manager && ctx.manager.tabs().length > 1}>\n <ContextMenuPrimitive.Item\n data-no-drag\n data-tauri-drag-region=\"false\"\n style=\"-webkit-app-region: no-drag; app-region: no-drag;\"\n onMouseDown={(e) => e.stopPropagation()}\n onPointerDown={(e) => e.stopPropagation()}\n onSelect={() => {\n setTimeout(() => ctx.manager?.closeOthers(tabId()), 0);\n }}\n class=\"relative flex cursor-pointer select-none items-center gap-2 rounded-xs px-2 py-1.5 text-xs outline-none transition-colors hover:bg-accent hover:text-accent-foreground pointer-events-auto [app-region:no-drag] [-webkit-app-region:no-drag]\"\n >\n <CircleX class=\"size-3.5 text-muted-foreground\" />\n Close Other Tabs\n </ContextMenuPrimitive.Item>\n <ContextMenuPrimitive.Item\n data-no-drag\n data-tauri-drag-region=\"false\"\n style=\"-webkit-app-region: no-drag; app-region: no-drag;\"\n onMouseDown={(e) => e.stopPropagation()}\n onPointerDown={(e) => e.stopPropagation()}\n onSelect={() => {\n setTimeout(() => ctx.manager?.closeAll(), 0);\n }}\n class=\"relative flex cursor-pointer select-none items-center gap-2 rounded-xs px-2 py-1.5 text-xs outline-none transition-colors hover:bg-accent hover:text-accent-foreground pointer-events-auto [app-region:no-drag] [-webkit-app-region:no-drag]\"\n >\n <CircleX class=\"size-3.5 text-muted-foreground\" />\n <span>Close All Tabs</span>\n </ContextMenuPrimitive.Item>\n </Show>\n </>\n }\n >\n {local.renderContextMenu!(local.tab || { id: tabId(), title: title() })}\n </Show>\n </ContextMenuPrimitive.Content>\n </ContextMenuPrimitive.Portal>\n </ContextMenuPrimitive.Root>\n </Show>\n );\n};\n\n/* --- 5. TitlebarTabAddButton using Nikala UI Button & Tooltip --- */\n\nexport interface TitlebarTabAddButtonProps extends JSX.HTMLAttributes<HTMLButtonElement> {\n class?: string;\n}\n\nexport const TitlebarTabAddButton: Component<TitlebarTabAddButtonProps> = (props) => {\n const [local, rest] = splitProps(props, [\"class\", \"style\"]);\n\n return (\n <div\n data-no-drag\n data-tauri-drag-region=\"false\"\n style={(\"-webkit-app-region: no-drag; app-region: no-drag;\" + (typeof local.style === \"string\" ? ` ${local.style}` : \"\")) as any}\n class=\"shrink-0 flex items-center z-20 pointer-events-auto [app-region:no-drag] [-webkit-app-region:no-drag]\"\n >\n <Tooltip>\n <TooltipTrigger\n as={Button}\n variant=\"ghost\"\n size=\"icon\"\n data-no-drag\n data-tauri-drag-region=\"false\"\n aria-label=\"Add new tab\"\n class={cn(\n \"size-6 rounded-md text-muted-foreground hover:bg-muted/80 hover:text-foreground cursor-pointer shrink-0\",\n local.class\n )}\n {...rest}\n >\n <Plus class=\"size-3.5\" />\n </TooltipTrigger>\n <TooltipContent class=\"text-[10px] py-1 px-2\">\n New tab (Ctrl+T)\n </TooltipContent>\n </Tooltip>\n </div>\n );\n};\n\n",
21
+ "type": "registry:ui"
22
+ }
23
+ ]
24
+ }