@rahulapgm/skyblue-ui 0.1.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 (49) hide show
  1. package/README.md +25 -0
  2. package/package.json +55 -0
  3. package/src/animation.tsx +117 -0
  4. package/src/autocomplete.tsx +271 -0
  5. package/src/badge.tsx +42 -0
  6. package/src/button.tsx +88 -0
  7. package/src/card.tsx +37 -0
  8. package/src/checkbox.tsx +36 -0
  9. package/src/chip.tsx +97 -0
  10. package/src/cluster.tsx +60 -0
  11. package/src/container.tsx +39 -0
  12. package/src/datepicker.tsx +59 -0
  13. package/src/drawer.tsx +126 -0
  14. package/src/dropdown.tsx +202 -0
  15. package/src/feature-card.tsx +33 -0
  16. package/src/floating-button.tsx +51 -0
  17. package/src/grid.tsx +94 -0
  18. package/src/hero-banner.tsx +82 -0
  19. package/src/icon-button.tsx +78 -0
  20. package/src/index.ts +32 -0
  21. package/src/input.tsx +62 -0
  22. package/src/label.tsx +20 -0
  23. package/src/loader.tsx +90 -0
  24. package/src/menu.tsx +100 -0
  25. package/src/message-box.tsx +46 -0
  26. package/src/metric.tsx +41 -0
  27. package/src/modal.tsx +110 -0
  28. package/src/navigation.tsx +147 -0
  29. package/src/number-input.tsx +127 -0
  30. package/src/pagination.tsx +102 -0
  31. package/src/phone-number-input.tsx +95 -0
  32. package/src/progress.tsx +65 -0
  33. package/src/radio.tsx +36 -0
  34. package/src/result.tsx +43 -0
  35. package/src/section.tsx +36 -0
  36. package/src/select.tsx +78 -0
  37. package/src/skeleton.tsx +17 -0
  38. package/src/stack.tsx +35 -0
  39. package/src/stat-card.tsx +69 -0
  40. package/src/steps.tsx +115 -0
  41. package/src/swatch.tsx +70 -0
  42. package/src/switch.tsx +60 -0
  43. package/src/table.tsx +88 -0
  44. package/src/tabs.tsx +100 -0
  45. package/src/textarea.tsx +52 -0
  46. package/src/timeline.tsx +60 -0
  47. package/src/toast.tsx +144 -0
  48. package/src/tooltip.tsx +56 -0
  49. package/src/utils.ts +3 -0
package/src/chip.tsx ADDED
@@ -0,0 +1,97 @@
1
+ "use client";
2
+
3
+ import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from "react";
4
+ import { X } from "lucide-react";
5
+
6
+ import { cn } from "./utils";
7
+
8
+ type ChipVariant = "brand" | "neutral" | "success" | "warning" | "error" | "info";
9
+ type ChipSize = "sm" | "md" | "lg";
10
+
11
+ type SharedChipProps = {
12
+ children: ReactNode;
13
+ variant?: ChipVariant;
14
+ size?: ChipSize;
15
+ leadingIcon?: ReactNode;
16
+ trailingIcon?: ReactNode;
17
+ onRemove?: () => void;
18
+ className?: string;
19
+ };
20
+
21
+ type ChipProps =
22
+ | (SharedChipProps & ButtonHTMLAttributes<HTMLButtonElement> & { onClick: () => void })
23
+ | (SharedChipProps & HTMLAttributes<HTMLSpanElement> & { onClick?: undefined });
24
+
25
+ const chipBaseClasses = "inline-flex items-center gap-2 rounded-full border font-bold transition-colors";
26
+
27
+ const chipVariantClasses: Record<ChipVariant, string> = {
28
+ brand:
29
+ "border-(--color-brand-primary-light) bg-(--color-brand-primary-light) text-(--color-brand-primary)",
30
+ neutral: "border-(--line-soft) bg-(--surface-card) text-(--foreground)",
31
+ success:
32
+ "border-(--color-status-success-light) bg-(--color-status-success-light) text-(--color-status-success)",
33
+ warning:
34
+ "border-(--color-status-warning-light) bg-(--color-status-warning-light) text-(--color-status-warning)",
35
+ error: "border-(--color-status-error-light) bg-(--color-status-error-light) text-(--color-status-error)",
36
+ info: "border-(--color-status-info-light) bg-(--color-status-info-light) text-(--color-status-info)",
37
+ };
38
+
39
+ const chipSizeClasses: Record<ChipSize, string> = {
40
+ sm: "min-h-9 px-3 type-caption",
41
+ md: "min-h-10 px-4 type-caption",
42
+ lg: "min-h-11 px-5 type-body",
43
+ };
44
+
45
+ export function Chip(props: ChipProps) {
46
+ const {
47
+ children,
48
+ variant = "neutral",
49
+ size = "md",
50
+ leadingIcon,
51
+ trailingIcon,
52
+ onRemove,
53
+ className,
54
+ ...rest
55
+ } = props;
56
+
57
+ const content = (
58
+ <>
59
+ {leadingIcon ? <span className="flex shrink-0 items-center">{leadingIcon}</span> : null}
60
+ <span>{children}</span>
61
+ {trailingIcon ? <span className="flex shrink-0 items-center">{trailingIcon}</span> : null}
62
+ {onRemove ? (
63
+ <button
64
+ type="button"
65
+ aria-label="Remove chip"
66
+ onClick={(event) => {
67
+ event.stopPropagation();
68
+ onRemove();
69
+ }}
70
+ className="ml-1 inline-flex h-5 w-5 items-center justify-center rounded-full bg-black/5 transition-colors hover:bg-black/10"
71
+ >
72
+ <X className="h-3.5 w-3.5" />
73
+ </button>
74
+ ) : null}
75
+ </>
76
+ );
77
+
78
+ const classes = cn(chipBaseClasses, chipVariantClasses[variant], chipSizeClasses[size], className);
79
+
80
+ if ("onClick" in props && props.onClick) {
81
+ const buttonProps = rest as ButtonHTMLAttributes<HTMLButtonElement>;
82
+
83
+ return (
84
+ <button {...buttonProps} type={buttonProps.type ?? "button"} className={classes}>
85
+ {content}
86
+ </button>
87
+ );
88
+ }
89
+
90
+ const spanProps = rest as HTMLAttributes<HTMLSpanElement>;
91
+
92
+ return (
93
+ <span {...spanProps} className={classes}>
94
+ {content}
95
+ </span>
96
+ );
97
+ }
@@ -0,0 +1,60 @@
1
+ import type { HTMLAttributes, ReactNode } from "react";
2
+
3
+ import { cn } from "./utils";
4
+
5
+ type ClusterGap = "xs" | "sm" | "md" | "lg" | "xl";
6
+ type ClusterAlign = "start" | "center" | "end" | "stretch";
7
+ type ClusterJustify = "start" | "center" | "end" | "between";
8
+
9
+ type ClusterProps = HTMLAttributes<HTMLDivElement> & {
10
+ children: ReactNode;
11
+ gap?: ClusterGap;
12
+ align?: ClusterAlign;
13
+ justify?: ClusterJustify;
14
+ };
15
+
16
+ const gapClasses: Record<ClusterGap, string> = {
17
+ xs: "gutter-cluster-xs",
18
+ sm: "gutter-cluster-sm",
19
+ md: "gutter-cluster-md",
20
+ lg: "gutter-cluster-lg",
21
+ xl: "gutter-grid-xl",
22
+ };
23
+
24
+ const alignClasses: Record<ClusterAlign, string> = {
25
+ start: "items-start",
26
+ center: "items-center",
27
+ end: "items-end",
28
+ stretch: "items-stretch",
29
+ };
30
+
31
+ const justifyClasses: Record<ClusterJustify, string> = {
32
+ start: "justify-start",
33
+ center: "justify-center",
34
+ end: "justify-end",
35
+ between: "justify-between",
36
+ };
37
+
38
+ export function Cluster({
39
+ children,
40
+ gap = "md",
41
+ align = "center",
42
+ justify = "start",
43
+ className,
44
+ ...props
45
+ }: ClusterProps) {
46
+ return (
47
+ <div
48
+ {...props}
49
+ className={cn(
50
+ "flex flex-wrap",
51
+ gapClasses[gap],
52
+ alignClasses[align],
53
+ justifyClasses[justify],
54
+ className,
55
+ )}
56
+ >
57
+ {children}
58
+ </div>
59
+ );
60
+ }
@@ -0,0 +1,39 @@
1
+ import type { HTMLAttributes, ReactNode } from "react";
2
+
3
+ import { cn } from "./utils";
4
+
5
+ type ContainerSize = "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "7xl" | "full";
6
+
7
+ type ContainerProps = HTMLAttributes<HTMLDivElement> & {
8
+ children: ReactNode;
9
+ size?: ContainerSize;
10
+ centered?: boolean;
11
+ };
12
+
13
+ const sizeClasses: Record<ContainerSize, string> = {
14
+ sm: "max-w-3xl",
15
+ md: "max-w-4xl",
16
+ lg: "max-w-5xl",
17
+ xl: "max-w-6xl",
18
+ "2xl": "max-w-[88rem]",
19
+ "3xl": "max-w-[96rem]",
20
+ "7xl": "max-w-7xl",
21
+ full: "max-w-full",
22
+ };
23
+
24
+ export function Container({
25
+ children,
26
+ size = "7xl",
27
+ centered = true,
28
+ className,
29
+ ...props
30
+ }: ContainerProps) {
31
+ return (
32
+ <div
33
+ {...props}
34
+ className={cn("gutter-page w-full", centered && "mx-auto", sizeClasses[size], className)}
35
+ >
36
+ {children}
37
+ </div>
38
+ );
39
+ }
@@ -0,0 +1,59 @@
1
+ import type { InputHTMLAttributes } from "react";
2
+ import { CalendarDays } from "lucide-react";
3
+
4
+ import { Label } from "./label";
5
+ import { cn } from "./utils";
6
+
7
+ type DatePickerProps = Omit<InputHTMLAttributes<HTMLInputElement>, "type"> & {
8
+ label?: string;
9
+ helperText?: string;
10
+ errorText?: string;
11
+ };
12
+
13
+ export function DatePicker({
14
+ label,
15
+ helperText,
16
+ errorText,
17
+ className,
18
+ id,
19
+ required,
20
+ ...props
21
+ }: DatePickerProps) {
22
+ const inputId = id ?? label?.toLowerCase().replace(/\s+/g, "-");
23
+ const hasError = Boolean(errorText);
24
+
25
+ return (
26
+ <div className="w-full">
27
+ {label ? (
28
+ <Label htmlFor={inputId} requiredMark={required} className="mb-2 block">
29
+ {label}
30
+ </Label>
31
+ ) : null}
32
+ <div
33
+ className={cn(
34
+ "flex min-h-12 items-center gap-3 rounded-2xl border bg-(--surface-card) px-4 shadow-(--shadow-sm) transition-colors",
35
+ hasError
36
+ ? "border-(--color-status-error)"
37
+ : "border-(--line-soft) hover:border-(--color-line-strong)",
38
+ )}
39
+ >
40
+ <CalendarDays className="h-4 w-4 text-(--ink-subtle)" />
41
+ <input
42
+ {...props}
43
+ id={inputId}
44
+ type="date"
45
+ required={required}
46
+ className={cn(
47
+ "type-body min-h-12 w-full bg-transparent text-(--foreground) outline-none",
48
+ className,
49
+ )}
50
+ />
51
+ </div>
52
+ {errorText ? (
53
+ <p className="type-caption mt-2 text-(--color-status-error)">{errorText}</p>
54
+ ) : helperText ? (
55
+ <p className="type-caption mt-2 text-(--ink-subtle)">{helperText}</p>
56
+ ) : null}
57
+ </div>
58
+ );
59
+ }
package/src/drawer.tsx ADDED
@@ -0,0 +1,126 @@
1
+ "use client";
2
+
3
+ import { AnimatePresence, motion } from "framer-motion";
4
+ import { useEffect, useId, type ReactNode } from "react";
5
+ import { X } from "lucide-react";
6
+
7
+ import { Button } from "./button";
8
+ import { cn } from "./utils";
9
+
10
+ type DrawerSide = "left" | "right";
11
+
12
+ type DrawerProps = {
13
+ open: boolean;
14
+ onClose: () => void;
15
+ title: string;
16
+ description?: string;
17
+ children: ReactNode;
18
+ side?: DrawerSide;
19
+ className?: string;
20
+ };
21
+
22
+ const sideClasses: Record<DrawerSide, string> = {
23
+ left: "left-0",
24
+ right: "right-0",
25
+ };
26
+
27
+ const panelMotion: Record<DrawerSide, { initial: { x: string }; exit: { x: string } }> = {
28
+ left: {
29
+ initial: { x: "-100%" },
30
+ exit: { x: "-100%" },
31
+ },
32
+ right: {
33
+ initial: { x: "100%" },
34
+ exit: { x: "100%" },
35
+ },
36
+ };
37
+
38
+ export function Drawer({
39
+ open,
40
+ onClose,
41
+ title,
42
+ description,
43
+ children,
44
+ side = "right",
45
+ className,
46
+ }: DrawerProps) {
47
+ const titleId = useId();
48
+ const descriptionId = useId();
49
+
50
+ useEffect(() => {
51
+ if (!open) {
52
+ return;
53
+ }
54
+
55
+ const previousOverflow = document.body.style.overflow;
56
+ document.body.style.overflow = "hidden";
57
+
58
+ function handleKeyDown(event: KeyboardEvent) {
59
+ if (event.key === "Escape") {
60
+ onClose();
61
+ }
62
+ }
63
+
64
+ window.addEventListener("keydown", handleKeyDown);
65
+
66
+ return () => {
67
+ document.body.style.overflow = previousOverflow;
68
+ window.removeEventListener("keydown", handleKeyDown);
69
+ };
70
+ }, [open, onClose]);
71
+
72
+ return (
73
+ <AnimatePresence>
74
+ {open ? (
75
+ <motion.div
76
+ className="fixed inset-0 z-40"
77
+ initial={{ opacity: 0 }}
78
+ animate={{ opacity: 1 }}
79
+ exit={{ opacity: 0 }}
80
+ transition={{ duration: 0.2, ease: "easeOut" }}
81
+ >
82
+ <motion.div className="absolute inset-0 bg-[rgba(11,26,42,0.48)]" onClick={onClose} aria-hidden />
83
+ <motion.aside
84
+ role="dialog"
85
+ aria-modal="true"
86
+ aria-labelledby={titleId}
87
+ aria-describedby={description ? descriptionId : undefined}
88
+ initial={panelMotion[side].initial}
89
+ animate={{ x: 0 }}
90
+ exit={panelMotion[side].exit}
91
+ transition={{ duration: 0.28, ease: "easeOut" }}
92
+ className={cn(
93
+ "absolute top-0 h-full w-full max-w-xl overflow-y-auto border-(--line-soft) bg-(--surface-card) p-6 shadow-(--shadow-lg)",
94
+ sideClasses[side],
95
+ side === "left" ? "border-r" : "border-l",
96
+ className,
97
+ )}
98
+ >
99
+ <div className="flex items-start justify-between gap-4">
100
+ <div>
101
+ <h2 id={titleId} className="type-subheading">
102
+ {title}
103
+ </h2>
104
+ {description ? (
105
+ <p id={descriptionId} className="type-body mt-2 text-(--ink-muted)">
106
+ {description}
107
+ </p>
108
+ ) : null}
109
+ </div>
110
+ <Button
111
+ onClick={onClose}
112
+ variant="tertiary"
113
+ size="sm"
114
+ aria-label="Close drawer"
115
+ className="min-h-10 px-3"
116
+ >
117
+ <X className="h-4 w-4" />
118
+ </Button>
119
+ </div>
120
+ <div className="mt-6">{children}</div>
121
+ </motion.aside>
122
+ </motion.div>
123
+ ) : null}
124
+ </AnimatePresence>
125
+ );
126
+ }
@@ -0,0 +1,202 @@
1
+ "use client";
2
+
3
+ import { ChevronDown, Check } from "lucide-react";
4
+ import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react";
5
+
6
+ import { cn } from "./utils";
7
+
8
+ export type DropdownOption = {
9
+ label: string;
10
+ value: string;
11
+ description?: string;
12
+ disabled?: boolean;
13
+ };
14
+
15
+ type DropdownProps = {
16
+ label?: string;
17
+ placeholder?: string;
18
+ options: DropdownOption[];
19
+ value?: string;
20
+ defaultValue?: string;
21
+ helperText?: string;
22
+ fullWidth?: boolean;
23
+ onValueChange?: (value: string) => void;
24
+ className?: string;
25
+ };
26
+
27
+ export function Dropdown({
28
+ label,
29
+ placeholder = "Select an option",
30
+ options,
31
+ value,
32
+ defaultValue,
33
+ helperText,
34
+ fullWidth = true,
35
+ onValueChange,
36
+ className,
37
+ }: DropdownProps) {
38
+ const id = useId();
39
+ const wrapperRef = useRef<HTMLDivElement>(null);
40
+ const [open, setOpen] = useState(false);
41
+ const [internalValue, setInternalValue] = useState(defaultValue ?? "");
42
+ const [activeIndex, setActiveIndex] = useState(0);
43
+
44
+ useEffect(() => {
45
+ function handleClickOutside(event: MouseEvent) {
46
+ if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) {
47
+ setOpen(false);
48
+ }
49
+ }
50
+
51
+ document.addEventListener("mousedown", handleClickOutside);
52
+
53
+ return () => {
54
+ document.removeEventListener("mousedown", handleClickOutside);
55
+ };
56
+ }, []);
57
+
58
+ const currentValue = value ?? internalValue;
59
+ const selectedOption = options.find((option) => option.value === currentValue);
60
+ const listboxId = `${id}-listbox`;
61
+ const selectedIndex = options.findIndex((option) => option.value === currentValue && !option.disabled);
62
+ const firstEnabledIndex = options.findIndex((option) => !option.disabled);
63
+ const initialActiveIndex =
64
+ selectedIndex >= 0 ? selectedIndex : firstEnabledIndex >= 0 ? firstEnabledIndex : 0;
65
+ const safeActiveIndex = options[activeIndex] ? activeIndex : initialActiveIndex;
66
+
67
+ function findNextEnabledIndex(startIndex: number, direction: 1 | -1) {
68
+ if (!options.length) {
69
+ return 0;
70
+ }
71
+
72
+ let nextIndex = startIndex;
73
+
74
+ for (let count = 0; count < options.length; count += 1) {
75
+ nextIndex = (nextIndex + direction + options.length) % options.length;
76
+ if (!options[nextIndex]?.disabled) {
77
+ return nextIndex;
78
+ }
79
+ }
80
+
81
+ return startIndex;
82
+ }
83
+
84
+ function commitValue(nextValue: string) {
85
+ if (value === undefined) {
86
+ setInternalValue(nextValue);
87
+ }
88
+
89
+ onValueChange?.(nextValue);
90
+ setOpen(false);
91
+ }
92
+
93
+ function handleKeyDown(event: KeyboardEvent<HTMLButtonElement>) {
94
+ if (!open && (event.key === "ArrowDown" || event.key === "Enter" || event.key === " ")) {
95
+ event.preventDefault();
96
+ setActiveIndex(initialActiveIndex);
97
+ setOpen(true);
98
+ return;
99
+ }
100
+
101
+ if (!open) {
102
+ return;
103
+ }
104
+
105
+ if (event.key === "Escape") {
106
+ setOpen(false);
107
+ return;
108
+ }
109
+
110
+ if (event.key === "ArrowDown") {
111
+ event.preventDefault();
112
+ setActiveIndex((current) => findNextEnabledIndex(current, 1));
113
+ }
114
+
115
+ if (event.key === "ArrowUp") {
116
+ event.preventDefault();
117
+ setActiveIndex((current) => findNextEnabledIndex(current, -1));
118
+ }
119
+
120
+ if (event.key === "Enter") {
121
+ event.preventDefault();
122
+ const option = options[safeActiveIndex];
123
+ if (option && !option.disabled) {
124
+ commitValue(option.value);
125
+ }
126
+ }
127
+
128
+ if (event.key === "Tab") {
129
+ setOpen(false);
130
+ }
131
+ }
132
+
133
+ return (
134
+ <div ref={wrapperRef} className={cn("relative", fullWidth && "w-full", className)}>
135
+ {label ? (
136
+ <label htmlFor={id} className="type-caption mb-2 block text-(--foreground)">
137
+ {label}
138
+ </label>
139
+ ) : null}
140
+ <button
141
+ id={id}
142
+ type="button"
143
+ onClick={() => {
144
+ setActiveIndex(initialActiveIndex);
145
+ setOpen((current) => !current);
146
+ }}
147
+ onKeyDown={handleKeyDown}
148
+ className="flex min-h-12 w-full items-center justify-between rounded-2xl border border-(--line-soft) bg-(--surface-card) px-4 py-3 text-left shadow-(--shadow-sm) transition-colors hover:border-(--color-line-strong)"
149
+ aria-expanded={open}
150
+ aria-haspopup="listbox"
151
+ aria-controls={listboxId}
152
+ >
153
+ <span className={cn("type-body", selectedOption ? "text-(--foreground)" : "text-(--ink-subtle)")}>
154
+ {selectedOption?.label ?? placeholder}
155
+ </span>
156
+ <ChevronDown
157
+ className={cn("h-4 w-4 text-(--ink-subtle) transition-transform", open && "rotate-180")}
158
+ />
159
+ </button>
160
+
161
+ {open ? (
162
+ <div className="absolute z-20 mt-2 w-full rounded-3xl border border-(--line-soft) bg-(--surface-card) p-2 shadow-(--shadow-lg)">
163
+ <div id={listboxId} role="listbox" aria-labelledby={id} className="space-y-1">
164
+ {options.map((option, index) => {
165
+ const selected = option.value === currentValue;
166
+
167
+ return (
168
+ <button
169
+ key={option.value}
170
+ id={`${id}-option-${option.value}`}
171
+ type="button"
172
+ role="option"
173
+ aria-selected={selected}
174
+ disabled={option.disabled}
175
+ onMouseEnter={() => setActiveIndex(index)}
176
+ onClick={() => commitValue(option.value)}
177
+ className={cn(
178
+ "flex w-full items-start justify-between rounded-2xl px-4 py-3 text-left transition-colors",
179
+ index === safeActiveIndex ? "bg-(--color-surface-hover)" : "bg-transparent",
180
+ option.disabled && "cursor-not-allowed opacity-50",
181
+ )}
182
+ >
183
+ <span>
184
+ <span className="type-body block text-(--foreground)">{option.label}</span>
185
+ {option.description ? (
186
+ <span className="type-caption mt-1 block text-(--ink-subtle)">
187
+ {option.description}
188
+ </span>
189
+ ) : null}
190
+ </span>
191
+ {selected ? <Check className="mt-1 h-4 w-4 text-(--color-brand-primary)" /> : null}
192
+ </button>
193
+ );
194
+ })}
195
+ </div>
196
+ </div>
197
+ ) : null}
198
+
199
+ {helperText ? <p className="type-caption mt-2 text-(--ink-subtle)">{helperText}</p> : null}
200
+ </div>
201
+ );
202
+ }
@@ -0,0 +1,33 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ import { Card } from "./card";
4
+ import { cn } from "./utils";
5
+
6
+ type FeatureCardProps = {
7
+ title: string;
8
+ description: string;
9
+ icon?: ReactNode;
10
+ eyebrow?: string;
11
+ className?: string;
12
+ };
13
+
14
+ export function FeatureCard({ title, description, icon, eyebrow, className }: FeatureCardProps) {
15
+ return (
16
+ <Card tone="glass" padding="md" className={cn("h-full", className)}>
17
+ <div className="flex items-start gap-4">
18
+ {icon ? (
19
+ <div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-(--color-brand-primary-light) text-(--color-brand-primary)">
20
+ {icon}
21
+ </div>
22
+ ) : null}
23
+
24
+ <div className="min-w-0 flex-1">
25
+ {eyebrow ? <p className="type-overline mb-1 text-(--color-brand-primary)">{eyebrow}</p> : null}
26
+ <h3 className="type-subheading text-(--foreground)">{title}</h3>
27
+ </div>
28
+ </div>
29
+
30
+ <p className="type-body mt-4 text-(--ink-muted)">{description}</p>
31
+ </Card>
32
+ );
33
+ }
@@ -0,0 +1,51 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ import { Button, type ButtonSize, type ButtonVariant } from "./button";
4
+ import { cn } from "./utils";
5
+
6
+ type FloatingButtonProps = {
7
+ label: string;
8
+ icon?: ReactNode;
9
+ href?: string;
10
+ onClick?: () => void;
11
+ variant?: ButtonVariant;
12
+ size?: ButtonSize;
13
+ position?: "bottom-right" | "bottom-left" | "top-right" | "top-left";
14
+ className?: string;
15
+ };
16
+
17
+ const positionClasses = {
18
+ "bottom-right": "bottom-6 right-6",
19
+ "bottom-left": "bottom-6 left-6",
20
+ "top-right": "right-6 top-6",
21
+ "top-left": "left-6 top-6",
22
+ } as const;
23
+
24
+ export function FloatingButton({
25
+ label,
26
+ icon,
27
+ href,
28
+ onClick,
29
+ variant = "primary",
30
+ size = "lg",
31
+ position = "bottom-right",
32
+ className,
33
+ }: FloatingButtonProps) {
34
+ const classes = cn("fixed z-30 shadow-(--shadow-primary)", positionClasses[position], className);
35
+
36
+ if (href) {
37
+ return (
38
+ <Button href={href} variant={variant} size={size} className={classes}>
39
+ {icon}
40
+ {label}
41
+ </Button>
42
+ );
43
+ }
44
+
45
+ return (
46
+ <Button onClick={onClick} variant={variant} size={size} className={classes}>
47
+ {icon}
48
+ {label}
49
+ </Button>
50
+ );
51
+ }