@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.
- package/README.md +25 -0
- package/package.json +55 -0
- package/src/animation.tsx +117 -0
- package/src/autocomplete.tsx +271 -0
- package/src/badge.tsx +42 -0
- package/src/button.tsx +88 -0
- package/src/card.tsx +37 -0
- package/src/checkbox.tsx +36 -0
- package/src/chip.tsx +97 -0
- package/src/cluster.tsx +60 -0
- package/src/container.tsx +39 -0
- package/src/datepicker.tsx +59 -0
- package/src/drawer.tsx +126 -0
- package/src/dropdown.tsx +202 -0
- package/src/feature-card.tsx +33 -0
- package/src/floating-button.tsx +51 -0
- package/src/grid.tsx +94 -0
- package/src/hero-banner.tsx +82 -0
- package/src/icon-button.tsx +78 -0
- package/src/index.ts +32 -0
- package/src/input.tsx +62 -0
- package/src/label.tsx +20 -0
- package/src/loader.tsx +90 -0
- package/src/menu.tsx +100 -0
- package/src/message-box.tsx +46 -0
- package/src/metric.tsx +41 -0
- package/src/modal.tsx +110 -0
- package/src/navigation.tsx +147 -0
- package/src/number-input.tsx +127 -0
- package/src/pagination.tsx +102 -0
- package/src/phone-number-input.tsx +95 -0
- package/src/progress.tsx +65 -0
- package/src/radio.tsx +36 -0
- package/src/result.tsx +43 -0
- package/src/section.tsx +36 -0
- package/src/select.tsx +78 -0
- package/src/skeleton.tsx +17 -0
- package/src/stack.tsx +35 -0
- package/src/stat-card.tsx +69 -0
- package/src/steps.tsx +115 -0
- package/src/swatch.tsx +70 -0
- package/src/switch.tsx +60 -0
- package/src/table.tsx +88 -0
- package/src/tabs.tsx +100 -0
- package/src/textarea.tsx +52 -0
- package/src/timeline.tsx +60 -0
- package/src/toast.tsx +144 -0
- package/src/tooltip.tsx +56 -0
- 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
|
+
}
|
package/src/cluster.tsx
ADDED
|
@@ -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
|
+
}
|
package/src/dropdown.tsx
ADDED
|
@@ -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
|
+
}
|