@oppulence/design-system 1.0.2

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 (80) hide show
  1. package/README.md +115 -0
  2. package/components.json +21 -0
  3. package/hooks/use-mobile.tsx +21 -0
  4. package/lib/utils.ts +6 -0
  5. package/package.json +104 -0
  6. package/postcss.config.mjs +8 -0
  7. package/src/components/atoms/aspect-ratio.tsx +21 -0
  8. package/src/components/atoms/avatar.tsx +91 -0
  9. package/src/components/atoms/badge.tsx +47 -0
  10. package/src/components/atoms/button.tsx +128 -0
  11. package/src/components/atoms/checkbox.tsx +24 -0
  12. package/src/components/atoms/container.tsx +42 -0
  13. package/src/components/atoms/heading.tsx +56 -0
  14. package/src/components/atoms/index.ts +21 -0
  15. package/src/components/atoms/input.tsx +18 -0
  16. package/src/components/atoms/kbd.tsx +23 -0
  17. package/src/components/atoms/label.tsx +15 -0
  18. package/src/components/atoms/logo.tsx +52 -0
  19. package/src/components/atoms/progress.tsx +79 -0
  20. package/src/components/atoms/separator.tsx +17 -0
  21. package/src/components/atoms/skeleton.tsx +13 -0
  22. package/src/components/atoms/slider.tsx +56 -0
  23. package/src/components/atoms/spinner.tsx +14 -0
  24. package/src/components/atoms/stack.tsx +126 -0
  25. package/src/components/atoms/switch.tsx +26 -0
  26. package/src/components/atoms/text.tsx +69 -0
  27. package/src/components/atoms/textarea.tsx +19 -0
  28. package/src/components/atoms/toggle.tsx +40 -0
  29. package/src/components/molecules/accordion.tsx +72 -0
  30. package/src/components/molecules/ai-chat.tsx +251 -0
  31. package/src/components/molecules/alert.tsx +131 -0
  32. package/src/components/molecules/breadcrumb.tsx +301 -0
  33. package/src/components/molecules/button-group.tsx +96 -0
  34. package/src/components/molecules/card.tsx +184 -0
  35. package/src/components/molecules/collapsible.tsx +21 -0
  36. package/src/components/molecules/command-search.tsx +148 -0
  37. package/src/components/molecules/empty.tsx +98 -0
  38. package/src/components/molecules/field.tsx +217 -0
  39. package/src/components/molecules/grid.tsx +141 -0
  40. package/src/components/molecules/hover-card.tsx +45 -0
  41. package/src/components/molecules/index.ts +29 -0
  42. package/src/components/molecules/input-group.tsx +151 -0
  43. package/src/components/molecules/input-otp.tsx +74 -0
  44. package/src/components/molecules/item.tsx +194 -0
  45. package/src/components/molecules/page-header.tsx +89 -0
  46. package/src/components/molecules/pagination.tsx +130 -0
  47. package/src/components/molecules/popover.tsx +96 -0
  48. package/src/components/molecules/radio-group.tsx +37 -0
  49. package/src/components/molecules/resizable.tsx +52 -0
  50. package/src/components/molecules/scroll-area.tsx +45 -0
  51. package/src/components/molecules/section.tsx +108 -0
  52. package/src/components/molecules/select.tsx +201 -0
  53. package/src/components/molecules/settings.tsx +197 -0
  54. package/src/components/molecules/table.tsx +111 -0
  55. package/src/components/molecules/tabs.tsx +74 -0
  56. package/src/components/molecules/theme-switcher.tsx +187 -0
  57. package/src/components/molecules/toggle-group.tsx +89 -0
  58. package/src/components/molecules/tooltip.tsx +66 -0
  59. package/src/components/organisms/alert-dialog.tsx +152 -0
  60. package/src/components/organisms/app-shell.tsx +939 -0
  61. package/src/components/organisms/calendar.tsx +212 -0
  62. package/src/components/organisms/carousel.tsx +230 -0
  63. package/src/components/organisms/chart.tsx +333 -0
  64. package/src/components/organisms/combobox.tsx +274 -0
  65. package/src/components/organisms/command.tsx +200 -0
  66. package/src/components/organisms/context-menu.tsx +229 -0
  67. package/src/components/organisms/dialog.tsx +134 -0
  68. package/src/components/organisms/drawer.tsx +123 -0
  69. package/src/components/organisms/dropdown-menu.tsx +256 -0
  70. package/src/components/organisms/index.ts +17 -0
  71. package/src/components/organisms/menubar.tsx +203 -0
  72. package/src/components/organisms/navigation-menu.tsx +143 -0
  73. package/src/components/organisms/page-layout.tsx +105 -0
  74. package/src/components/organisms/sheet.tsx +126 -0
  75. package/src/components/organisms/sidebar.tsx +723 -0
  76. package/src/components/organisms/sonner.tsx +41 -0
  77. package/src/components/ui/index.ts +3 -0
  78. package/src/index.ts +3 -0
  79. package/src/styles/globals.css +297 -0
  80. package/tailwind.config.ts +77 -0
@@ -0,0 +1,197 @@
1
+ import { cva, type VariantProps } from "class-variance-authority";
2
+ import * as React from "react";
3
+
4
+ import { Heading } from "../atoms/heading";
5
+ import { Stack } from "../atoms/stack";
6
+ import { Text } from "../atoms/text";
7
+
8
+ const settingRowVariants = cva(
9
+ "flex w-full items-start justify-between gap-4 py-4 first:pt-0 last:pb-0",
10
+ {
11
+ variants: {
12
+ size: {
13
+ default: "py-4",
14
+ sm: "py-3",
15
+ lg: "py-5",
16
+ },
17
+ },
18
+ defaultVariants: {
19
+ size: "default",
20
+ },
21
+ },
22
+ );
23
+
24
+ interface SettingRowProps
25
+ extends
26
+ Omit<React.ComponentProps<"div">, "className">,
27
+ VariantProps<typeof settingRowVariants> {
28
+ /** The setting label */
29
+ label: string;
30
+ /** Optional description text */
31
+ description?: string;
32
+ /** Whether the setting is disabled */
33
+ disabled?: boolean;
34
+ }
35
+
36
+ /**
37
+ * A horizontal row for a single setting with label/description on the left
38
+ * and a control (switch, button, select, etc.) on the right.
39
+ */
40
+ function SettingRow({
41
+ label,
42
+ description,
43
+ disabled,
44
+ size,
45
+ children,
46
+ ...props
47
+ }: SettingRowProps) {
48
+ return (
49
+ <div
50
+ data-slot="setting-row"
51
+ data-disabled={disabled || undefined}
52
+ className={settingRowVariants({ size })}
53
+ {...props}
54
+ >
55
+ <div className="min-w-0 flex-1">
56
+ <Stack gap="1">
57
+ <Text weight="medium" variant={disabled ? "muted" : undefined}>
58
+ {label}
59
+ </Text>
60
+ {description && (
61
+ <Text size="sm" variant="muted">
62
+ {description}
63
+ </Text>
64
+ )}
65
+ </Stack>
66
+ </div>
67
+ <div className="flex shrink-0 items-center">{children}</div>
68
+ </div>
69
+ );
70
+ }
71
+
72
+ interface SettingGroupProps extends Omit<
73
+ React.ComponentProps<"div">,
74
+ "className"
75
+ > {
76
+ /** Whether to show dividers between rows */
77
+ divided?: boolean;
78
+ }
79
+
80
+ /**
81
+ * Groups multiple SettingRow components together, optionally with dividers.
82
+ */
83
+ function SettingGroup({
84
+ divided = true,
85
+ children,
86
+ ...props
87
+ }: SettingGroupProps) {
88
+ return (
89
+ <div
90
+ data-slot="setting-group"
91
+ data-divided={divided || undefined}
92
+ className={
93
+ divided
94
+ ? "[&>[data-slot=setting-row]:not(:last-child)]:border-border [&>[data-slot=setting-row]:not(:last-child)]:border-b"
95
+ : ""
96
+ }
97
+ {...props}
98
+ >
99
+ {children}
100
+ </div>
101
+ );
102
+ }
103
+
104
+ interface SettingLabelProps extends Omit<
105
+ React.ComponentProps<"div">,
106
+ "className"
107
+ > {}
108
+
109
+ /**
110
+ * Custom label area for complex setting rows. Use when you need more than just text.
111
+ */
112
+ function SettingLabel({ children, ...props }: SettingLabelProps) {
113
+ return (
114
+ <div data-slot="setting-label" className="min-w-0 flex-1" {...props}>
115
+ {children}
116
+ </div>
117
+ );
118
+ }
119
+
120
+ interface SettingControlProps extends Omit<
121
+ React.ComponentProps<"div">,
122
+ "className"
123
+ > {}
124
+
125
+ /**
126
+ * Control area for setting rows. Use when you need multiple controls or custom layout.
127
+ */
128
+ function SettingControl({ children, ...props }: SettingControlProps) {
129
+ return (
130
+ <div
131
+ data-slot="setting-control"
132
+ className="flex shrink-0 items-center gap-2"
133
+ {...props}
134
+ >
135
+ {children}
136
+ </div>
137
+ );
138
+ }
139
+
140
+ interface SettingsCardProps extends Omit<
141
+ React.ComponentProps<"div">,
142
+ "className"
143
+ > {
144
+ /** Card title */
145
+ title: string;
146
+ /** Optional description */
147
+ description?: string;
148
+ /** Hint text shown in footer (left side) */
149
+ hint?: React.ReactNode;
150
+ /** Action button/content shown in footer (right side) */
151
+ action?: React.ReactNode;
152
+ }
153
+
154
+ /**
155
+ * A self-contained settings card with title, description, content area, and footer.
156
+ * Footer shows hint text on left and action button on right.
157
+ */
158
+ function SettingsCard({
159
+ title,
160
+ description,
161
+ hint,
162
+ action,
163
+ children,
164
+ ...props
165
+ }: SettingsCardProps) {
166
+ const hasFooter = hint || action;
167
+
168
+ return (
169
+ <div
170
+ data-slot="settings-card"
171
+ className="bg-card text-card-foreground rounded-xl border shadow-sm"
172
+ {...props}
173
+ >
174
+ <div className="px-6 pt-6 pb-4">
175
+ <Stack gap="1">
176
+ <Text weight="semibold">{title}</Text>
177
+ {description && (
178
+ <Text size="sm" variant="muted">
179
+ {description}
180
+ </Text>
181
+ )}
182
+ </Stack>
183
+ </div>
184
+ <div className="px-6 pb-6">{children}</div>
185
+ {hasFooter && (
186
+ <div className="border-t bg-muted/30 px-6 py-4">
187
+ <div className="flex items-center justify-between gap-4">
188
+ <div className="text-muted-foreground text-sm">{hint}</div>
189
+ <div className="flex shrink-0 items-center gap-2">{action}</div>
190
+ </div>
191
+ </div>
192
+ )}
193
+ </div>
194
+ );
195
+ }
196
+
197
+ export { SettingControl, SettingGroup, SettingLabel, SettingRow, SettingsCard };
@@ -0,0 +1,111 @@
1
+ import * as React from "react";
2
+
3
+ function Table({
4
+ variant = "default",
5
+ ...props
6
+ }: Omit<React.ComponentProps<"table">, "className"> & {
7
+ variant?: "default" | "bordered";
8
+ }) {
9
+ return (
10
+ <div
11
+ data-slot="table-container"
12
+ data-variant={variant}
13
+ className="relative w-full overflow-x-auto data-[variant=bordered]:border data-[variant=bordered]:rounded-lg"
14
+ >
15
+ <table
16
+ data-slot="table"
17
+ className="w-full caption-bottom text-sm"
18
+ {...props}
19
+ />
20
+ </div>
21
+ );
22
+ }
23
+
24
+ function TableHeader({
25
+ ...props
26
+ }: Omit<React.ComponentProps<"thead">, "className">) {
27
+ return (
28
+ <thead data-slot="table-header" className="[&_tr]:border-b" {...props} />
29
+ );
30
+ }
31
+
32
+ function TableBody({
33
+ ...props
34
+ }: Omit<React.ComponentProps<"tbody">, "className">) {
35
+ return (
36
+ <tbody
37
+ data-slot="table-body"
38
+ className="[&_tr:last-child]:border-0"
39
+ {...props}
40
+ />
41
+ );
42
+ }
43
+
44
+ function TableFooter({
45
+ ...props
46
+ }: Omit<React.ComponentProps<"tfoot">, "className">) {
47
+ return (
48
+ <tfoot
49
+ data-slot="table-footer"
50
+ className="bg-muted/50 border-t font-medium [&>tr]:last:border-b-0"
51
+ {...props}
52
+ />
53
+ );
54
+ }
55
+
56
+ function TableRow({ ...props }: Omit<React.ComponentProps<"tr">, "className">) {
57
+ return (
58
+ <tr
59
+ data-slot="table-row"
60
+ className="hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors"
61
+ {...props}
62
+ />
63
+ );
64
+ }
65
+
66
+ function TableHead({
67
+ ...props
68
+ }: Omit<React.ComponentProps<"th">, "className">) {
69
+ return (
70
+ <th
71
+ data-slot="table-head"
72
+ className="text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0"
73
+ {...props}
74
+ />
75
+ );
76
+ }
77
+
78
+ function TableCell({
79
+ ...props
80
+ }: Omit<React.ComponentProps<"td">, "className">) {
81
+ return (
82
+ <td
83
+ data-slot="table-cell"
84
+ className="p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0"
85
+ {...props}
86
+ />
87
+ );
88
+ }
89
+
90
+ function TableCaption({
91
+ ...props
92
+ }: Omit<React.ComponentProps<"caption">, "className">) {
93
+ return (
94
+ <caption
95
+ data-slot="table-caption"
96
+ className="text-muted-foreground mt-4 text-sm"
97
+ {...props}
98
+ />
99
+ );
100
+ }
101
+
102
+ export {
103
+ Table,
104
+ TableBody,
105
+ TableCaption,
106
+ TableCell,
107
+ TableFooter,
108
+ TableHead,
109
+ TableHeader,
110
+ TableRow,
111
+ };
@@ -0,0 +1,74 @@
1
+ "use client";
2
+
3
+ import { Tabs as TabsPrimitive } from "@base-ui/react/tabs";
4
+ import { cva, type VariantProps } from "class-variance-authority";
5
+
6
+ function Tabs({
7
+ orientation = "horizontal",
8
+ ...props
9
+ }: Omit<TabsPrimitive.Root.Props, "className">) {
10
+ return (
11
+ <TabsPrimitive.Root
12
+ data-slot="tabs"
13
+ data-orientation={orientation}
14
+ className="group/tabs flex data-[orientation=horizontal]:flex-col"
15
+ {...props}
16
+ />
17
+ );
18
+ }
19
+
20
+ const tabsListVariants = cva(
21
+ "rounded-lg p-[3px] group-data-horizontal/tabs:h-8 data-[variant=line]:rounded-none data-[variant=underline]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col",
22
+ {
23
+ variants: {
24
+ variant: {
25
+ default: "bg-muted",
26
+ line: "gap-1 bg-transparent",
27
+ underline:
28
+ "p-0 pb-px bg-transparent w-full justify-start items-stretch shadow-[inset_0_-1px_0_0_var(--color-border)]",
29
+ },
30
+ },
31
+ defaultVariants: {
32
+ variant: "default",
33
+ },
34
+ },
35
+ );
36
+
37
+ function TabsList({
38
+ variant = "default",
39
+ ...props
40
+ }: Omit<TabsPrimitive.List.Props, "className"> &
41
+ VariantProps<typeof tabsListVariants>) {
42
+ return (
43
+ <TabsPrimitive.List
44
+ data-slot="tabs-list"
45
+ data-variant={variant}
46
+ className={tabsListVariants({ variant })}
47
+ {...props}
48
+ />
49
+ );
50
+ }
51
+
52
+ function TabsTrigger({ ...props }: Omit<TabsPrimitive.Tab.Props, "className">) {
53
+ return (
54
+ <TabsPrimitive.Tab
55
+ data-slot="tabs-trigger"
56
+ className="gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none group-data-[variant=underline]/tabs-list:data-active:shadow-none [&_svg:not([class*='size-'])]:size-4 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent group-data-[variant=underline]/tabs-list:bg-transparent group-data-[variant=underline]/tabs-list:data-active:bg-transparent group-data-[variant=underline]/tabs-list:px-3 group-data-[variant=underline]/tabs-list:py-2 group-data-[variant=underline]/tabs-list:mb-0 group-data-[variant=underline]/tabs-list:hover:bg-muted group-data-[variant=underline]/tabs-list:rounded-t-md group-data-[variant=underline]/tabs-list:flex-none dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=underline]/tabs-list:data-active:border-transparent dark:group-data-[variant=underline]/tabs-list:data-active:bg-transparent data-active:bg-background dark:data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 data-active:text-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:after:bg-foreground group-data-[variant=underline]/tabs-list:after:bg-primary group-data-[variant=line]/tabs-list:data-active:after:opacity-100 group-data-[variant=underline]/tabs-list:after:h-[3px] group-data-[variant=underline]/tabs-list:after:bottom-0 group-data-[variant=underline]/tabs-list:after:rounded-t-sm group-data-[variant=underline]/tabs-list:h-auto group-data-[variant=underline]/tabs-list:data-active:after:opacity-100"
57
+ {...props}
58
+ />
59
+ );
60
+ }
61
+
62
+ function TabsContent({
63
+ ...props
64
+ }: Omit<TabsPrimitive.Panel.Props, "className">) {
65
+ return (
66
+ <TabsPrimitive.Panel
67
+ data-slot="tabs-content"
68
+ className="text-sm flex-1 outline-none animate-in fade-in-0 duration-200"
69
+ {...props}
70
+ />
71
+ );
72
+ }
73
+
74
+ export { Tabs, TabsContent, TabsList, tabsListVariants, TabsTrigger };
@@ -0,0 +1,187 @@
1
+ "use client";
2
+
3
+ import { cva, type VariantProps } from "class-variance-authority";
4
+ import { MonitorIcon, MoonIcon, SunIcon } from "lucide-react";
5
+ import * as React from "react";
6
+
7
+ const themeSwitcherVariants = cva(
8
+ "inline-flex items-center rounded-full p-0.5 bg-muted",
9
+ {
10
+ variants: {
11
+ size: {
12
+ sm: "gap-0.5",
13
+ default: "gap-0.5",
14
+ },
15
+ },
16
+ defaultVariants: {
17
+ size: "default",
18
+ },
19
+ },
20
+ );
21
+
22
+ const themeSwitcherButtonVariants = cva(
23
+ "inline-flex items-center justify-center rounded-full transition-all duration-200",
24
+ {
25
+ variants: {
26
+ size: {
27
+ sm: "size-6 [&_svg]:size-3",
28
+ default: "size-7 [&_svg]:size-3.5",
29
+ },
30
+ isActive: {
31
+ true: "bg-background text-foreground shadow-sm",
32
+ false: "text-muted-foreground hover:text-foreground",
33
+ },
34
+ },
35
+ defaultVariants: {
36
+ size: "default",
37
+ isActive: false,
38
+ },
39
+ },
40
+ );
41
+
42
+ type Theme = "light" | "dark" | "system";
43
+
44
+ interface ThemeSwitcherProps
45
+ extends
46
+ Omit<React.ComponentProps<"div">, "className" | "onChange">,
47
+ VariantProps<typeof themeSwitcherVariants> {
48
+ /** Current theme value */
49
+ value?: Theme;
50
+ /** Default theme value (uncontrolled) */
51
+ defaultValue?: Theme;
52
+ /** Called when theme changes */
53
+ onChange?: (theme: Theme) => void;
54
+ /** Show system option */
55
+ showSystem?: boolean;
56
+ }
57
+
58
+ function ThemeSwitcher({
59
+ value: valueProp,
60
+ defaultValue = "system",
61
+ onChange,
62
+ showSystem = true,
63
+ size = "default",
64
+ ...props
65
+ }: ThemeSwitcherProps) {
66
+ const [internalValue, setInternalValue] = React.useState<Theme>(defaultValue);
67
+ const value = valueProp ?? internalValue;
68
+
69
+ const handleChange = (newTheme: Theme) => {
70
+ if (valueProp === undefined) {
71
+ setInternalValue(newTheme);
72
+ }
73
+ onChange?.(newTheme);
74
+ };
75
+
76
+ const options: { value: Theme; icon: React.ReactNode; label: string }[] = [
77
+ { value: "light", icon: <SunIcon />, label: "Light mode" },
78
+ { value: "dark", icon: <MoonIcon />, label: "Dark mode" },
79
+ ...(showSystem
80
+ ? [
81
+ {
82
+ value: "system" as Theme,
83
+ icon: <MonitorIcon />,
84
+ label: "System theme",
85
+ },
86
+ ]
87
+ : []),
88
+ ];
89
+
90
+ return (
91
+ <div
92
+ data-slot="theme-switcher"
93
+ role="radiogroup"
94
+ aria-label="Theme"
95
+ className={themeSwitcherVariants({ size })}
96
+ {...props}
97
+ >
98
+ {options.map((option) => (
99
+ <button
100
+ key={option.value}
101
+ type="button"
102
+ role="radio"
103
+ aria-checked={value === option.value}
104
+ aria-label={option.label}
105
+ onClick={() => handleChange(option.value)}
106
+ className={themeSwitcherButtonVariants({
107
+ size,
108
+ isActive: value === option.value,
109
+ })}
110
+ >
111
+ {option.icon}
112
+ </button>
113
+ ))}
114
+ </div>
115
+ );
116
+ }
117
+
118
+ // Simple light/dark toggle for compact spaces
119
+ interface ThemeToggleProps extends Omit<
120
+ React.ComponentProps<"button">,
121
+ "className" | "onChange"
122
+ > {
123
+ /** Current theme - true for dark, false for light */
124
+ isDark?: boolean;
125
+ /** Called when theme changes */
126
+ onChange?: (isDark: boolean) => void;
127
+ /** Size variant */
128
+ size?: "sm" | "default";
129
+ }
130
+
131
+ function ThemeToggle({
132
+ isDark: isDarkProp,
133
+ onChange,
134
+ size = "default",
135
+ ...props
136
+ }: ThemeToggleProps) {
137
+ const [internalIsDark, setInternalIsDark] = React.useState(false);
138
+ const isDark = isDarkProp ?? internalIsDark;
139
+
140
+ const handleToggle = () => {
141
+ const newValue = !isDark;
142
+ if (isDarkProp === undefined) {
143
+ setInternalIsDark(newValue);
144
+ }
145
+ onChange?.(newValue);
146
+ };
147
+
148
+ const iconSize = size === "sm" ? "size-3" : "size-3.5";
149
+ const buttonSize = size === "sm" ? "h-6 w-12" : "h-7 w-14";
150
+ const thumbSize = size === "sm" ? "size-5" : "size-6";
151
+ const thumbTranslate =
152
+ size === "sm" ? "translate-x-[26px]" : "translate-x-[30px]";
153
+
154
+ return (
155
+ <button
156
+ type="button"
157
+ role="switch"
158
+ aria-checked={isDark}
159
+ aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
160
+ onClick={handleToggle}
161
+ data-slot="theme-toggle"
162
+ className={`${buttonSize} relative inline-flex items-center rounded-full bg-muted p-0.5 transition-colors`}
163
+ {...props}
164
+ >
165
+ {/* Background icons */}
166
+ <span className="absolute inset-0 flex items-center justify-between px-1.5">
167
+ <SunIcon className={`${iconSize} text-amber-500`} />
168
+ <MoonIcon className={`${iconSize} text-blue-400`} />
169
+ </span>
170
+ {/* Sliding thumb */}
171
+ <span
172
+ className={`${thumbSize} relative z-10 flex items-center justify-center rounded-full bg-background shadow-sm transition-transform duration-200 ${
173
+ isDark ? thumbTranslate : "translate-x-0"
174
+ }`}
175
+ >
176
+ {isDark ? (
177
+ <MoonIcon className={`${iconSize} text-blue-500`} />
178
+ ) : (
179
+ <SunIcon className={`${iconSize} text-amber-500`} />
180
+ )}
181
+ </span>
182
+ </button>
183
+ );
184
+ }
185
+
186
+ export { ThemeSwitcher, ThemeToggle };
187
+ export type { ThemeSwitcherProps, ThemeToggleProps, Theme };
@@ -0,0 +1,89 @@
1
+ "use client";
2
+
3
+ import { Toggle as TogglePrimitive } from "@base-ui/react/toggle";
4
+ import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group";
5
+ import { type VariantProps } from "class-variance-authority";
6
+ import * as React from "react";
7
+
8
+ import { cn } from "../../../lib/utils";
9
+ import { toggleVariants } from "../atoms/toggle";
10
+
11
+ const ToggleGroupContext = React.createContext<
12
+ VariantProps<typeof toggleVariants> & {
13
+ spacing?: number;
14
+ orientation?: "horizontal" | "vertical";
15
+ }
16
+ >({
17
+ size: "default",
18
+ variant: "default",
19
+ spacing: 0,
20
+ orientation: "horizontal",
21
+ });
22
+
23
+ type ToggleGroupProps = Omit<ToggleGroupPrimitive.Props, "className"> &
24
+ VariantProps<typeof toggleVariants> & {
25
+ spacing?: number;
26
+ orientation?: "horizontal" | "vertical";
27
+ };
28
+
29
+ function ToggleGroup({
30
+ variant,
31
+ size,
32
+ spacing = 0,
33
+ orientation = "horizontal",
34
+ children,
35
+ ...props
36
+ }: ToggleGroupProps) {
37
+ return (
38
+ <ToggleGroupPrimitive
39
+ data-slot="toggle-group"
40
+ data-variant={variant}
41
+ data-size={size}
42
+ data-spacing={spacing}
43
+ data-orientation={orientation}
44
+ style={{ "--gap": spacing } as React.CSSProperties}
45
+ className="rounded-md data-[spacing=0]:data-[variant=outline]:shadow-xs group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] data-[orientation=vertical]:flex-col data-[orientation=vertical]:items-stretch"
46
+ {...props}
47
+ >
48
+ <ToggleGroupContext.Provider
49
+ value={{ variant, size, spacing, orientation }}
50
+ >
51
+ {children}
52
+ </ToggleGroupContext.Provider>
53
+ </ToggleGroupPrimitive>
54
+ );
55
+ }
56
+
57
+ type ToggleGroupItemProps = Omit<TogglePrimitive.Props, "className"> &
58
+ VariantProps<typeof toggleVariants>;
59
+
60
+ function ToggleGroupItem({
61
+ children,
62
+ variant = "default",
63
+ size = "default",
64
+ ...props
65
+ }: ToggleGroupItemProps) {
66
+ const context = React.useContext(ToggleGroupContext);
67
+
68
+ return (
69
+ <TogglePrimitive
70
+ data-slot="toggle-group-item"
71
+ data-variant={context.variant || variant}
72
+ data-size={context.size || size}
73
+ data-spacing={context.spacing}
74
+ className={cn(
75
+ "data-[state=on]:bg-muted group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 group-data-[spacing=0]/toggle-group:shadow-none group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-md group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-md group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-md group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-md shrink-0 focus:z-10 focus-visible:z-10 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t",
76
+ toggleVariants({
77
+ variant: context.variant || variant,
78
+ size: context.size || size,
79
+ }),
80
+ )}
81
+ {...props}
82
+ >
83
+ {children}
84
+ </TogglePrimitive>
85
+ );
86
+ }
87
+
88
+ export { ToggleGroup, ToggleGroupItem };
89
+ export type { ToggleGroupItemProps, ToggleGroupProps };