@nikala-ui/core 0.0.0-nightly.bb5956a
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -0
- package/package.json +37 -0
- package/registry/accordion.json +18 -0
- package/registry/alert.json +18 -0
- package/registry/avatar.json +17 -0
- package/registry/badge.json +18 -0
- package/registry/banner.json +19 -0
- package/registry/breadcrumb.json +17 -0
- package/registry/button.json +18 -0
- package/registry/card.json +17 -0
- package/registry/checkbox.json +17 -0
- package/registry/command.json +25 -0
- package/registry/dialog.json +18 -0
- package/registry/dropdown-menu.json +18 -0
- package/registry/index.json +333 -0
- package/registry/input-group.json +21 -0
- package/registry/input.json +17 -0
- package/registry/kbd.json +18 -0
- package/registry/label.json +18 -0
- package/registry/list.json +20 -0
- package/registry/popover.json +19 -0
- package/registry/radio-group.json +18 -0
- package/registry/select.json +18 -0
- package/registry/separator.json +17 -0
- package/registry/sheet.json +19 -0
- package/registry/skeleton.json +17 -0
- package/registry/switch.json +17 -0
- package/registry/tabs.json +17 -0
- package/registry/textarea.json +17 -0
- package/registry/theme-manager.json +38 -0
- package/registry/toast.json +20 -0
- package/registry/tooltip.json +18 -0
- package/src/index.css +11 -0
- package/src/lib/cn.ts +9 -0
- package/src/registry/components/ui/accordion.tsx +132 -0
- package/src/registry/components/ui/alert.tsx +155 -0
- package/src/registry/components/ui/avatar.tsx +114 -0
- package/src/registry/components/ui/badge.tsx +50 -0
- package/src/registry/components/ui/banner.tsx +189 -0
- package/src/registry/components/ui/breadcrumb.tsx +136 -0
- package/src/registry/components/ui/button.tsx +63 -0
- package/src/registry/components/ui/card.tsx +113 -0
- package/src/registry/components/ui/checkbox.tsx +74 -0
- package/src/registry/components/ui/command.tsx +286 -0
- package/src/registry/components/ui/dialog.tsx +195 -0
- package/src/registry/components/ui/dropdown-menu.tsx +265 -0
- package/src/registry/components/ui/input-group.tsx +80 -0
- package/src/registry/components/ui/input.tsx +28 -0
- package/src/registry/components/ui/kbd.tsx +73 -0
- package/src/registry/components/ui/label.tsx +30 -0
- package/src/registry/components/ui/list.tsx +222 -0
- package/src/registry/components/ui/popover.tsx +90 -0
- package/src/registry/components/ui/radio-group.tsx +95 -0
- package/src/registry/components/ui/select.tsx +153 -0
- package/src/registry/components/ui/separator.tsx +37 -0
- package/src/registry/components/ui/sheet.tsx +216 -0
- package/src/registry/components/ui/skeleton.tsx +24 -0
- package/src/registry/components/ui/switch.tsx +68 -0
- package/src/registry/components/ui/tabs.tsx +204 -0
- package/src/registry/components/ui/textarea.tsx +82 -0
- package/src/registry/components/ui/theme-toggle.tsx +200 -0
- package/src/registry/components/ui/toast.tsx +124 -0
- package/src/registry/components/ui/tooltip.tsx +41 -0
- package/src/registry/index.ts +50 -0
- package/src/registry/metadata.ts +167 -0
- package/src/registry/providers/theme-provider.tsx +208 -0
- package/src/registry/providers/theme-script.tsx +58 -0
- package/src/registry/providers/theme-transitions.ts +108 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tabs",
|
|
3
|
+
"title": "Tabs",
|
|
4
|
+
"description": "A set of layered sections of content displayed one at a time.",
|
|
5
|
+
"type": "registry:ui",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"clsx",
|
|
8
|
+
"tailwind-merge"
|
|
9
|
+
],
|
|
10
|
+
"files": [
|
|
11
|
+
{
|
|
12
|
+
"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-background 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
|
+
"type": "registry:ui"
|
|
15
|
+
}
|
|
16
|
+
]
|
|
17
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "textarea",
|
|
3
|
+
"title": "Textarea",
|
|
4
|
+
"description": "A multi-line text input field with responsive focus styles.",
|
|
5
|
+
"type": "registry:ui",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"clsx",
|
|
8
|
+
"tailwind-merge"
|
|
9
|
+
],
|
|
10
|
+
"files": [
|
|
11
|
+
{
|
|
12
|
+
"path": "ui/textarea.tsx",
|
|
13
|
+
"content": "// src/components/ui/textarea.tsx\nimport {\n createSignal,\n splitProps,\n Show,\n type Component,\n type JSX,\n} from \"solid-js\";\nimport { cn } from \"@/lib/cn\";\n\nexport interface TextareaProps\n extends JSX.TextareaHTMLAttributes<HTMLTextAreaElement> {\n /** Uncontrolled initial default value */\n defaultValue?: string | number;\n /** Maximum allowed character limit */\n maxLength?: number;\n /** Whether to show live character count badge */\n showCount?: boolean;\n class?: string;\n}\n\n/**\n * Nikala UI Textarea component with optional live character counter and limit indicators.\n */\nexport const Textarea: Component<TextareaProps> = (props) => {\n const [local, rest] = splitProps(props, [\n \"maxLength\",\n \"showCount\",\n \"value\",\n \"defaultValue\",\n \"onInput\",\n \"class\",\n ]);\n\n /* Internal reactive signal for character count tracking */\n const [currentValue, setCurrentValue] = createSignal<string>(\n String(local.value || local.defaultValue || \"\")\n );\n\n const handleInput: JSX.EventHandlerUnion<HTMLTextAreaElement, InputEvent> = (\n e\n ) => {\n setCurrentValue(e.currentTarget.value);\n if (typeof local.onInput === \"function\") {\n (local.onInput as (e: InputEvent) => void)(e);\n }\n };\n\n const count = () => currentValue().length;\n const isAtLimit = () =>\n local.maxLength !== undefined && count() >= local.maxLength;\n\n return (\n <div class=\"relative flex flex-col w-full\">\n <textarea\n value={local.value !== undefined ? String(local.value) : currentValue()}\n maxLength={local.maxLength}\n onInput={handleInput}\n class={cn(\n \"flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-2xs transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm\",\n local.class\n )}\n {...rest}\n />\n\n {/* Dynamic Character Counter */}\n <Show when={local.showCount ?? Boolean(local.maxLength)}>\n <div\n class={cn(\n \"mt-1 text-right text-[11px] font-mono transition-colors select-none\",\n isAtLimit() ? \"text-rose-500 font-bold\" : \"text-muted-foreground\"\n )}\n >\n <span>{count()}</span>\n <Show when={local.maxLength}>\n <span> / {local.maxLength}</span>\n </Show>\n </div>\n </Show>\n </div>\n );\n};",
|
|
14
|
+
"type": "registry:ui"
|
|
15
|
+
}
|
|
16
|
+
]
|
|
17
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "theme-manager",
|
|
3
|
+
"title": "Theme Manager",
|
|
4
|
+
"description": "Zero-dependency ThemeProvider and ThemeToggle component for switching light, dark, and system themes.",
|
|
5
|
+
"type": "registry:ui",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"clsx",
|
|
8
|
+
"tailwind-merge",
|
|
9
|
+
"@kobalte/core",
|
|
10
|
+
"lucide-solid"
|
|
11
|
+
],
|
|
12
|
+
"registryDependencies": [
|
|
13
|
+
"button",
|
|
14
|
+
"dropdown-menu"
|
|
15
|
+
],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"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
|
+
"type": "registry:ui"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"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
|
+
"type": "registry:ui"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"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
|
+
"type": "registry:ui"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"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=\"outline\"\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 p-3\">\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 </DropdownMenuContent>\n </Show>\n </DropdownMenu>\n );\n};",
|
|
35
|
+
"type": "registry:ui"
|
|
36
|
+
}
|
|
37
|
+
]
|
|
38
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "toast",
|
|
3
|
+
"title": "Toast / Sonner",
|
|
4
|
+
"description": "A succinct message displayed temporarily in a toast region, built on Kobalte primitives.",
|
|
5
|
+
"type": "registry:ui",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"clsx",
|
|
8
|
+
"tailwind-merge",
|
|
9
|
+
"class-variance-authority",
|
|
10
|
+
"lucide-solid",
|
|
11
|
+
"@kobalte/core"
|
|
12
|
+
],
|
|
13
|
+
"files": [
|
|
14
|
+
{
|
|
15
|
+
"path": "ui/toast.tsx",
|
|
16
|
+
"content": "import { splitProps, type Component, type JSX, type ComponentProps } from \"solid-js\";\nimport { Toast as KobalteToast, toaster } from \"@kobalte/core/toast\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport { X, CircleCheck, Info, CircleAlert, TriangleAlert } from \"lucide-solid\";\nimport { cn } from \"@/lib/cn\";\n\nexport const toastVariants = cva(\n \"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-lg border p-4 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--kb-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--kb-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[opened]:animate-in data-[closed]:animate-out data-[swipe=end]:animate-out data-[closed]:fade-out-80 data-[closed]:slide-out-to-right-full data-[opened]:slide-in-from-top-full data-[opened]:sm:slide-in-from-bottom-full\",\n {\n variants: {\n variant: {\n default: \"border-border bg-background text-foreground\",\n success: \"border-emerald-500/20 bg-emerald-500/10 text-emerald-900 dark:text-emerald-200 border-emerald-500/30\",\n destructive: \"border-destructive/30 bg-destructive/10 text-destructive dark:text-red-300\",\n warning: \"border-amber-500/20 bg-amber-500/10 text-amber-900 dark:text-amber-200 border-amber-500/30\",\n info: \"border-sky-500/20 bg-sky-500/10 text-sky-900 dark:text-sky-200 border-sky-500/30\",\n },\n },\n defaultVariants: {\n variant: \"default\",\n },\n }\n);\n\nexport interface ToastProps\n extends ComponentProps<typeof KobalteToast>,\n VariantProps<typeof toastVariants> {\n class?: string;\n}\n\nexport const Toast: Component<ToastProps> = (props) => {\n const [local, rest] = splitProps(props, [\"class\", \"variant\"]);\n\n return (\n <KobalteToast\n class={cn(toastVariants({ variant: local.variant }), local.class)}\n {...rest}\n />\n );\n};\n\nexport const ToastTitle: Component<ComponentProps<typeof KobalteToast.Title>> = (props) => {\n const [local, rest] = splitProps(props, [\"class\"]);\n\n return (\n <KobalteToast.Title\n class={cn(\"text-sm font-semibold [&+div]:text-xs\", local.class)}\n {...rest}\n />\n );\n};\n\nexport const ToastDescription: Component<ComponentProps<typeof KobalteToast.Description>> = (props) => {\n const [local, rest] = splitProps(props, [\"class\"]);\n\n return (\n <KobalteToast.Description\n class={cn(\"text-sm opacity-90\", local.class)}\n {...rest}\n />\n );\n};\n\nexport const ToastCloseButton: Component<ComponentProps<typeof KobalteToast.CloseButton>> = (props) => {\n const [local, rest] = splitProps(props, [\"class\", \"children\"]);\n\n return (\n <KobalteToast.CloseButton\n class={cn(\n \"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100\",\n local.class\n )}\n {...rest}\n >\n {local.children || <X class=\"h-4 w-4\" />}\n </KobalteToast.CloseButton>\n );\n};\n\nexport const ToastRegion: Component<ComponentProps<typeof KobalteToast.Region>> = (props) => {\n const [local, rest] = splitProps(props, [\"class\"]);\n\n return (\n <KobalteToast.Region\n class={cn(\n \"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]\",\n local.class\n )}\n {...rest}\n />\n );\n};\n\nexport const ToastList: Component<ComponentProps<typeof KobalteToast.List>> = (props) => {\n const [local, rest] = splitProps(props, [\"class\"]);\n\n return <KobalteToast.List class={cn(\"flex flex-col gap-2\", local.class)} {...rest} />;\n};\n\n/* --- Helper Function to Trigger Toast Notifications --- */\nexport interface ShowToastOptions {\n title: string;\n description?: string;\n variant?: \"default\" | \"success\" | \"destructive\" | \"warning\" | \"info\";\n duration?: number;\n}\n\nexport const showToast = (options: ShowToastOptions) => {\n return toaster.show((props) => (\n <Toast toastId={props.toastId} variant={options.variant || \"default\"}>\n <div class=\"flex items-start gap-3\">\n {options.variant === \"success\" && <CircleCheck class=\"h-5 w-5 text-emerald-500 shrink-0 mt-0.5\" />}\n {options.variant === \"destructive\" && <CircleAlert class=\"h-5 w-5 text-red-500 shrink-0 mt-0.5\" />}\n {options.variant === \"warning\" && <TriangleAlert class=\"h-5 w-5 text-amber-500 shrink-0 mt-0.5\" />}\n {options.variant === \"info\" && <Info class=\"h-5 w-5 text-sky-500 shrink-0 mt-0.5\" />}\n <div class=\"grid gap-1\">\n <ToastTitle>{options.title}</ToastTitle>\n {options.description && <ToastDescription>{options.description}</ToastDescription>}\n </div>\n </div>\n <ToastCloseButton />\n </Toast>\n ));\n};",
|
|
17
|
+
"type": "registry:ui"
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tooltip",
|
|
3
|
+
"title": "Tooltip",
|
|
4
|
+
"description": "A popup that displays information related to an element when the element receives keyboard focus or the mouse hovers over it, built on Kobalte primitives.",
|
|
5
|
+
"type": "registry:ui",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"clsx",
|
|
8
|
+
"tailwind-merge",
|
|
9
|
+
"@kobalte/core"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "ui/tooltip.tsx",
|
|
14
|
+
"content": "import { splitProps, type Component, type ComponentProps } from \"solid-js\";\nimport { Tooltip as KobalteTooltip } from \"@kobalte/core/tooltip\";\nimport { cn } from \"@/lib/cn\";\n\nexport const Tooltip = KobalteTooltip;\n\nexport const TooltipTrigger = KobalteTooltip.Trigger;\n\nexport const TooltipArrow: Component<ComponentProps<typeof KobalteTooltip.Arrow>> = (props) => {\n const [local, rest] = splitProps(props, [\"class\"]);\n\n return (\n <KobalteTooltip.Arrow\n size={8}\n class={cn(\"fill-popover stroke-border\", local.class)}\n {...rest}\n />\n );\n};\n\nexport interface TooltipContentProps extends ComponentProps<typeof KobalteTooltip.Content> {\n class?: string;\n}\n\nexport const TooltipContent: Component<TooltipContentProps> = (props) => {\n const [local, rest] = splitProps(props, [\"class\", \"children\"]);\n\n return (\n <KobalteTooltip.Portal>\n <KobalteTooltip.Content\n class={cn(\n \"z-50 rounded-md border border-border bg-popover px-3 py-1.5 text-xs text-popover-foreground shadow-md transition-all animate-in fade-in-0 zoom-in-95 data-closed:animate-out data-[closed]:fade-out-0 data-closed:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2\",\n local.class\n )}\n {...rest}\n >\n {local.children}\n </KobalteTooltip.Content>\n </KobalteTooltip.Portal>\n );\n};",
|
|
15
|
+
"type": "registry:ui"
|
|
16
|
+
}
|
|
17
|
+
]
|
|
18
|
+
}
|
package/src/index.css
ADDED
package/src/lib/cn.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { splitProps, type JSX, type ValidComponent } from "solid-js";
|
|
2
|
+
import * as AccordionPrimitive from "@kobalte/core/accordion";
|
|
3
|
+
import type { PolymorphicProps } from "@kobalte/core/polymorphic";
|
|
4
|
+
import { cn } from "@/lib/cn";
|
|
5
|
+
|
|
6
|
+
export type AccordionRootProps<T extends ValidComponent = "div"> = Omit<
|
|
7
|
+
AccordionPrimitive.AccordionRootProps<T>,
|
|
8
|
+
"value" | "defaultValue"
|
|
9
|
+
> & {
|
|
10
|
+
/** Accordion mode: "single" allows one open item, "multiple" allows many */
|
|
11
|
+
type?: "single" | "multiple";
|
|
12
|
+
/** Controlled value (single string or array of strings) */
|
|
13
|
+
value?: string | string[];
|
|
14
|
+
/** Default initial value (single string or array of strings) */
|
|
15
|
+
defaultValue?: string | string[];
|
|
16
|
+
class?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Root Accordion component built on top of Kobalte headless primitives.
|
|
21
|
+
*/
|
|
22
|
+
export const Accordion = <T extends ValidComponent = "div">(
|
|
23
|
+
props: PolymorphicProps<T, AccordionRootProps<T>>
|
|
24
|
+
) => {
|
|
25
|
+
const [local, rest] = splitProps(props as AccordionRootProps, [
|
|
26
|
+
"class",
|
|
27
|
+
"type",
|
|
28
|
+
"multiple",
|
|
29
|
+
"value",
|
|
30
|
+
"defaultValue",
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
// Support both `type="multiple"` and `multiple={true}`
|
|
34
|
+
const isMultiple = () => local.multiple ?? local.type === "multiple";
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<AccordionPrimitive.Root
|
|
38
|
+
multiple={isMultiple()}
|
|
39
|
+
value={local.value}
|
|
40
|
+
defaultValue={local.defaultValue}
|
|
41
|
+
class={cn("w-full divide-y divide-border", local.class)}
|
|
42
|
+
{...(rest as any)}
|
|
43
|
+
/>
|
|
44
|
+
);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type AccordionItemProps<T extends ValidComponent = "div"> =
|
|
48
|
+
AccordionPrimitive.AccordionItemProps<T> & {
|
|
49
|
+
class?: string;
|
|
50
|
+
value: string;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Individual Accordion section item wrapper.
|
|
55
|
+
*/
|
|
56
|
+
export const AccordionItem = <T extends ValidComponent = "div">(
|
|
57
|
+
props: PolymorphicProps<T, AccordionItemProps<T>>
|
|
58
|
+
) => {
|
|
59
|
+
const [local, rest] = splitProps(props as AccordionItemProps, ["class"]);
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<AccordionPrimitive.Item
|
|
63
|
+
class={cn("border-b border-border", local.class)}
|
|
64
|
+
{...rest}
|
|
65
|
+
/>
|
|
66
|
+
);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export type AccordionTriggerProps<T extends ValidComponent = "button"> =
|
|
70
|
+
AccordionPrimitive.AccordionTriggerProps<T> & {
|
|
71
|
+
class?: string;
|
|
72
|
+
children?: JSX.Element;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Header trigger button toggling the expansion of an AccordionItem.
|
|
77
|
+
*/
|
|
78
|
+
export const AccordionTrigger = <T extends ValidComponent = "button">(
|
|
79
|
+
props: PolymorphicProps<T, AccordionTriggerProps<T>>
|
|
80
|
+
) => {
|
|
81
|
+
const [local, rest] = splitProps(props as AccordionTriggerProps, ["class", "children"]);
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<AccordionPrimitive.Header class="flex">
|
|
85
|
+
<AccordionPrimitive.Trigger
|
|
86
|
+
class={cn(
|
|
87
|
+
"flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline [&[data-expanded]>svg]:rotate-180 cursor-pointer text-foreground",
|
|
88
|
+
local.class
|
|
89
|
+
)}
|
|
90
|
+
{...rest}
|
|
91
|
+
>
|
|
92
|
+
{local.children}
|
|
93
|
+
<svg
|
|
94
|
+
class="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200"
|
|
95
|
+
viewBox="0 0 24 24"
|
|
96
|
+
fill="none"
|
|
97
|
+
stroke="currentColor"
|
|
98
|
+
stroke-width="2"
|
|
99
|
+
>
|
|
100
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
|
|
101
|
+
</svg>
|
|
102
|
+
</AccordionPrimitive.Trigger>
|
|
103
|
+
</AccordionPrimitive.Header>
|
|
104
|
+
);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
export type AccordionContentProps<T extends ValidComponent = "div"> =
|
|
108
|
+
AccordionPrimitive.AccordionContentProps<T> & {
|
|
109
|
+
class?: string;
|
|
110
|
+
children?: JSX.Element;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Collapsible content panel revealed when the associated AccordionItem is open.
|
|
115
|
+
*/
|
|
116
|
+
export const AccordionContent = <T extends ValidComponent = "div">(
|
|
117
|
+
props: PolymorphicProps<T, AccordionContentProps<T>>
|
|
118
|
+
) => {
|
|
119
|
+
const [local, rest] = splitProps(props as AccordionContentProps, ["class", "children"]);
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<AccordionPrimitive.Content
|
|
123
|
+
class={cn(
|
|
124
|
+
"overflow-hidden text-sm text-muted-foreground transition-all",
|
|
125
|
+
local.class
|
|
126
|
+
)}
|
|
127
|
+
{...rest}
|
|
128
|
+
>
|
|
129
|
+
<div class="pb-4 pt-0">{local.children}</div>
|
|
130
|
+
</AccordionPrimitive.Content>
|
|
131
|
+
);
|
|
132
|
+
};
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createSignal,
|
|
3
|
+
splitProps,
|
|
4
|
+
onMount,
|
|
5
|
+
onCleanup,
|
|
6
|
+
Show,
|
|
7
|
+
type Component,
|
|
8
|
+
type JSX,
|
|
9
|
+
} from "solid-js";
|
|
10
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
11
|
+
import { cn } from "@/lib/cn";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* CVA variants for Alert status styles and color themes.
|
|
15
|
+
*/
|
|
16
|
+
export const alertVariants = cva(
|
|
17
|
+
"relative w-full rounded-lg border p-4 transition-all duration-200 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
|
18
|
+
{
|
|
19
|
+
variants: {
|
|
20
|
+
variant: {
|
|
21
|
+
default:
|
|
22
|
+
"bg-background text-foreground border-border",
|
|
23
|
+
info:
|
|
24
|
+
"bg-blue-50 text-blue-900 border-blue-200 dark:bg-blue-950/50 dark:text-blue-200 dark:border-blue-900/50",
|
|
25
|
+
success:
|
|
26
|
+
"bg-emerald-50 text-emerald-900 border-emerald-200 dark:bg-emerald-950/50 dark:text-emerald-200 dark:border-emerald-900/50",
|
|
27
|
+
warning:
|
|
28
|
+
"bg-amber-50 text-amber-900 border-amber-200 dark:bg-amber-950/50 dark:text-amber-200 dark:border-amber-950/50",
|
|
29
|
+
destructive:
|
|
30
|
+
"bg-destructive/15 text-destructive border-destructive/30",
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
defaultVariants: {
|
|
34
|
+
variant: "default",
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
export interface AlertProps
|
|
40
|
+
extends JSX.HTMLAttributes<HTMLDivElement>,
|
|
41
|
+
VariantProps<typeof alertVariants> {
|
|
42
|
+
/** Display an interactive close (X) button */
|
|
43
|
+
closable?: boolean;
|
|
44
|
+
/** Auto-dismiss timer duration in milliseconds */
|
|
45
|
+
duration?: number;
|
|
46
|
+
/** Callback fired when the alert is dismissed */
|
|
47
|
+
onClose?: () => void;
|
|
48
|
+
class?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Nikala UI Alert notification banner with auto-dismiss timer and theme variants.
|
|
53
|
+
*/
|
|
54
|
+
export const Alert: Component<AlertProps> = (props) => {
|
|
55
|
+
const [local, rest] = splitProps(props, [
|
|
56
|
+
"variant",
|
|
57
|
+
"closable",
|
|
58
|
+
"duration",
|
|
59
|
+
"onClose",
|
|
60
|
+
"class",
|
|
61
|
+
"children",
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
const [isVisible, setIsVisible] = createSignal(true);
|
|
65
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
66
|
+
|
|
67
|
+
const handleClose = () => {
|
|
68
|
+
setIsVisible(false);
|
|
69
|
+
if (typeof local.onClose === "function") {
|
|
70
|
+
local.onClose();
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
onMount(() => {
|
|
75
|
+
if (local.duration && local.duration > 0) {
|
|
76
|
+
timer = setTimeout(() => {
|
|
77
|
+
handleClose();
|
|
78
|
+
}, local.duration);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
onCleanup(() => {
|
|
83
|
+
if (timer) clearTimeout(timer);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<Show when={isVisible()}>
|
|
88
|
+
<div
|
|
89
|
+
role="alert"
|
|
90
|
+
class={cn(
|
|
91
|
+
alertVariants({ variant: local.variant }),
|
|
92
|
+
"animate-in fade-in-0 duration-200",
|
|
93
|
+
local.class
|
|
94
|
+
)}
|
|
95
|
+
{...rest}
|
|
96
|
+
>
|
|
97
|
+
{local.children}
|
|
98
|
+
<Show when={local.closable || typeof local.onClose === "function"}>
|
|
99
|
+
<button
|
|
100
|
+
type="button"
|
|
101
|
+
aria-label="Close alert"
|
|
102
|
+
onClick={handleClose}
|
|
103
|
+
class="absolute right-3 top-3 rounded-md p-1 text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-50 transition-colors cursor-pointer"
|
|
104
|
+
>
|
|
105
|
+
<svg
|
|
106
|
+
class="h-4 w-4"
|
|
107
|
+
viewBox="0 0 24 24"
|
|
108
|
+
fill="none"
|
|
109
|
+
stroke="currentColor"
|
|
110
|
+
stroke-width="2"
|
|
111
|
+
>
|
|
112
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
113
|
+
</svg>
|
|
114
|
+
</button>
|
|
115
|
+
</Show>
|
|
116
|
+
</div>
|
|
117
|
+
</Show>
|
|
118
|
+
);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
export interface AlertTitleProps extends JSX.HTMLAttributes<HTMLHeadingElement> {
|
|
122
|
+
class?: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Header title element for the Alert banner.
|
|
127
|
+
*/
|
|
128
|
+
export const AlertTitle: Component<AlertTitleProps> = (props) => {
|
|
129
|
+
const [local, rest] = splitProps(props, ["class"]);
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<h5
|
|
133
|
+
class={cn("mb-1 font-medium leading-none tracking-tight text-foreground", local.class)}
|
|
134
|
+
{...rest}
|
|
135
|
+
/>
|
|
136
|
+
);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
export interface AlertDescriptionProps extends JSX.HTMLAttributes<HTMLDivElement> {
|
|
140
|
+
class?: string;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Body text content for the Alert banner.
|
|
145
|
+
*/
|
|
146
|
+
export const AlertDescription: Component<AlertDescriptionProps> = (props) => {
|
|
147
|
+
const [local, rest] = splitProps(props, ["class"]);
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<div
|
|
151
|
+
class={cn("text-sm [&_p]:leading-relaxed text-muted-foreground", local.class)}
|
|
152
|
+
{...rest}
|
|
153
|
+
/>
|
|
154
|
+
);
|
|
155
|
+
};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createSignal,
|
|
3
|
+
createEffect,
|
|
4
|
+
Show,
|
|
5
|
+
splitProps,
|
|
6
|
+
type Component,
|
|
7
|
+
type JSX,
|
|
8
|
+
} from "solid-js";
|
|
9
|
+
import { cn } from "@/lib/cn";
|
|
10
|
+
|
|
11
|
+
export interface AvatarProps extends JSX.HTMLAttributes<HTMLDivElement> {
|
|
12
|
+
class?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Root Avatar container component.
|
|
17
|
+
*/
|
|
18
|
+
export const Avatar: Component<AvatarProps> = (props) => {
|
|
19
|
+
const [local, rest] = splitProps(props, ["class"]);
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<div
|
|
23
|
+
class={cn(
|
|
24
|
+
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full border border-border bg-muted",
|
|
25
|
+
local.class
|
|
26
|
+
)}
|
|
27
|
+
{...rest}
|
|
28
|
+
/>
|
|
29
|
+
);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export interface AvatarImageProps
|
|
33
|
+
extends JSX.ImgHTMLAttributes<HTMLImageElement> {
|
|
34
|
+
class?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Image element for the Avatar component with background status loader.
|
|
39
|
+
*/
|
|
40
|
+
export const AvatarImage: Component<AvatarImageProps> = (props) => {
|
|
41
|
+
const [local, rest] = splitProps(props, [
|
|
42
|
+
"class",
|
|
43
|
+
"src",
|
|
44
|
+
"alt",
|
|
45
|
+
"onLoad",
|
|
46
|
+
"onError",
|
|
47
|
+
]);
|
|
48
|
+
const [status, setStatus] = createSignal<"loading" | "loaded" | "error">(
|
|
49
|
+
"loading"
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
/* Pre-test image loading in background JS to prevent native broken icon flashes */
|
|
53
|
+
createEffect(() => {
|
|
54
|
+
const src = local.src;
|
|
55
|
+
if (!src) {
|
|
56
|
+
setStatus("error");
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const img = new Image();
|
|
61
|
+
img.src = src;
|
|
62
|
+
img.onload = () => setStatus("loaded");
|
|
63
|
+
img.onerror = () => setStatus("error");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const handleLoad: JSX.EventHandlerUnion<HTMLImageElement, Event> = (e) => {
|
|
67
|
+
setStatus("loaded");
|
|
68
|
+
if (typeof local.onLoad === "function") {
|
|
69
|
+
(local.onLoad as (e: Event) => void)(e);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const handleError: JSX.EventHandlerUnion<HTMLImageElement, Event> = (e) => {
|
|
74
|
+
setStatus("error");
|
|
75
|
+
if (typeof local.onError === "function") {
|
|
76
|
+
(local.onError as (e: Event) => void)(e);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<Show when={status() === "loaded"}>
|
|
82
|
+
<img
|
|
83
|
+
src={local.src}
|
|
84
|
+
alt={local.alt}
|
|
85
|
+
class={cn("aspect-square h-full w-full object-cover", local.class)}
|
|
86
|
+
onLoad={handleLoad}
|
|
87
|
+
onError={handleError}
|
|
88
|
+
{...rest}
|
|
89
|
+
/>
|
|
90
|
+
</Show>
|
|
91
|
+
);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export interface AvatarFallbackProps
|
|
95
|
+
extends JSX.HTMLAttributes<HTMLDivElement> {
|
|
96
|
+
class?: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Fallback container for rendering initials or icons when avatar image is missing/broken.
|
|
101
|
+
*/
|
|
102
|
+
export const AvatarFallback: Component<AvatarFallbackProps> = (props) => {
|
|
103
|
+
const [local, rest] = splitProps(props, ["class"]);
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<div
|
|
107
|
+
class={cn(
|
|
108
|
+
"flex h-full w-full items-center justify-center bg-muted text-sm font-medium text-muted-foreground select-none",
|
|
109
|
+
local.class
|
|
110
|
+
)}
|
|
111
|
+
{...rest}
|
|
112
|
+
/>
|
|
113
|
+
);
|
|
114
|
+
};
|