@aereamart/design-system 0.3.0 → 0.3.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.
- package/package.json +43 -43
- package/src/Accordion.tsx +57 -57
- package/src/AlertBanner.tsx +61 -61
- package/src/Badge.tsx +27 -27
- package/src/BottomSheet.tsx +59 -59
- package/src/Button.tsx +54 -54
- package/src/Card.tsx +38 -38
- package/src/DataTable.tsx +63 -63
- package/src/DatePicker.tsx +43 -43
- package/src/Drawer.tsx +70 -70
- package/src/EmptyState.tsx +36 -36
- package/src/Input.tsx +30 -30
- package/src/Menu.tsx +72 -72
- package/src/Modal.tsx +62 -62
- package/src/NavigationBar.tsx +67 -67
- package/src/Pagination.tsx +68 -68
- package/src/Popover.tsx +33 -33
- package/src/SegmentedControl.tsx +62 -62
- package/src/Select.tsx +32 -32
- package/src/Sidebar.tsx +72 -72
- package/src/Snackbar.tsx +71 -71
- package/src/Spinner.tsx +19 -19
- package/src/Tabs.tsx +76 -76
- package/src/Textarea.tsx +30 -30
- package/src/Tooltip.tsx +24 -24
- package/src/TreeView.tsx +104 -104
- package/src/cn.ts +2 -2
package/package.json
CHANGED
|
@@ -1,43 +1,43 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@aereamart/design-system",
|
|
3
|
-
"version": "0.3.
|
|
4
|
-
"description": "Aereamart shared React UI primitives (Badge, Button, Input, Select, Textarea, Modal, Spinner)",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"main": "./dist/index.js",
|
|
7
|
-
"module": "./dist/index.js",
|
|
8
|
-
"types": "./dist/index.d.ts",
|
|
9
|
-
"exports": {
|
|
10
|
-
".": {
|
|
11
|
-
"types": "./dist/index.d.ts",
|
|
12
|
-
"import": "./dist/index.js"
|
|
13
|
-
}
|
|
14
|
-
},
|
|
15
|
-
"files": [
|
|
16
|
-
"dist",
|
|
17
|
-
"src"
|
|
18
|
-
],
|
|
19
|
-
"scripts": {
|
|
20
|
-
"build": "tsc -p tsconfig.json",
|
|
21
|
-
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
22
|
-
"lint": "oxlint src"
|
|
23
|
-
},
|
|
24
|
-
"peerDependencies": {
|
|
25
|
-
"react": "^19.2.8",
|
|
26
|
-
"react-dom": "^19.2.8"
|
|
27
|
-
},
|
|
28
|
-
"dependencies": {
|
|
29
|
-
"lucide-react": "^1.30.0"
|
|
30
|
-
},
|
|
31
|
-
"devDependencies": {
|
|
32
|
-
"@types/react": "^19.2.17",
|
|
33
|
-
"@types/react-dom": "^19.2.3",
|
|
34
|
-
"oxlint": "^1.75.0",
|
|
35
|
-
"typescript": "~6.0.2"
|
|
36
|
-
},
|
|
37
|
-
"keywords": [
|
|
38
|
-
"ui",
|
|
39
|
-
"components",
|
|
40
|
-
"aereamart"
|
|
41
|
-
],
|
|
42
|
-
"license": "MIT"
|
|
43
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@aereamart/design-system",
|
|
3
|
+
"version": "0.3.2",
|
|
4
|
+
"description": "Aereamart shared React UI primitives (Badge, Button, Input, Select, Textarea, Modal, Spinner)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"src"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsc -p tsconfig.json",
|
|
21
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
22
|
+
"lint": "oxlint src"
|
|
23
|
+
},
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"react": "^19.2.8",
|
|
26
|
+
"react-dom": "^19.2.8"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"lucide-react": "^1.30.0"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@types/react": "^19.2.17",
|
|
33
|
+
"@types/react-dom": "^19.2.3",
|
|
34
|
+
"oxlint": "^1.75.0",
|
|
35
|
+
"typescript": "~6.0.2"
|
|
36
|
+
},
|
|
37
|
+
"keywords": [
|
|
38
|
+
"ui",
|
|
39
|
+
"components",
|
|
40
|
+
"aereamart"
|
|
41
|
+
],
|
|
42
|
+
"license": "MIT"
|
|
43
|
+
}
|
package/src/Accordion.tsx
CHANGED
|
@@ -1,58 +1,58 @@
|
|
|
1
|
-
import { useState } from 'react';
|
|
2
|
-
import { ChevronDown } from 'lucide-react';
|
|
3
|
-
import { cn } from './cn';
|
|
4
|
-
|
|
5
|
-
export interface AccordionItem {
|
|
6
|
-
title: string;
|
|
7
|
-
content: React.ReactNode;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export interface AccordionProps {
|
|
11
|
-
items: AccordionItem[];
|
|
12
|
-
defaultOpen?: number | null;
|
|
13
|
-
className?: string;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export function Accordion({ items, defaultOpen = null, className }: AccordionProps) {
|
|
17
|
-
const [open, setOpen] = useState<number | null>(defaultOpen);
|
|
18
|
-
|
|
19
|
-
return (
|
|
20
|
-
<div
|
|
21
|
-
className={cn(
|
|
22
|
-
'divide-y divide-slate-200 overflow-hidden rounded-xl border border-slate-200 bg-white',
|
|
23
|
-
className
|
|
24
|
-
)}
|
|
25
|
-
>
|
|
26
|
-
{items.map((item, i) => {
|
|
27
|
-
const isOpen = open === i;
|
|
28
|
-
const uid = `accordion-${i}`;
|
|
29
|
-
return (
|
|
30
|
-
<div key={uid}>
|
|
31
|
-
<button
|
|
32
|
-
type="button"
|
|
33
|
-
aria-expanded={isOpen}
|
|
34
|
-
aria-controls={`${uid}-panel`}
|
|
35
|
-
onClick={() => setOpen(isOpen ? null : i)}
|
|
36
|
-
className="flex w-full items-center justify-between gap-4 px-5 py-4 text-left text-sm font-semibold text-slate-900 transition-colors hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-lime-500/40"
|
|
37
|
-
>
|
|
38
|
-
{item.title}
|
|
39
|
-
<ChevronDown
|
|
40
|
-
aria-hidden="true"
|
|
41
|
-
className={cn('h-4 w-4 shrink-0 text-slate-400 transition-transform', isOpen && 'rotate-180')}
|
|
42
|
-
/>
|
|
43
|
-
</button>
|
|
44
|
-
{isOpen && (
|
|
45
|
-
<div
|
|
46
|
-
id={`${uid}-panel`}
|
|
47
|
-
role="region"
|
|
48
|
-
className="border-t border-slate-100 px-5 py-4 text-sm leading-relaxed text-slate-600"
|
|
49
|
-
>
|
|
50
|
-
{item.content}
|
|
51
|
-
</div>
|
|
52
|
-
)}
|
|
53
|
-
</div>
|
|
54
|
-
);
|
|
55
|
-
})}
|
|
56
|
-
</div>
|
|
57
|
-
);
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { ChevronDown } from 'lucide-react';
|
|
3
|
+
import { cn } from './cn';
|
|
4
|
+
|
|
5
|
+
export interface AccordionItem {
|
|
6
|
+
title: string;
|
|
7
|
+
content: React.ReactNode;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface AccordionProps {
|
|
11
|
+
items: AccordionItem[];
|
|
12
|
+
defaultOpen?: number | null;
|
|
13
|
+
className?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function Accordion({ items, defaultOpen = null, className }: AccordionProps) {
|
|
17
|
+
const [open, setOpen] = useState<number | null>(defaultOpen);
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<div
|
|
21
|
+
className={cn(
|
|
22
|
+
'divide-y divide-slate-200 overflow-hidden rounded-xl border border-slate-200 bg-white',
|
|
23
|
+
className
|
|
24
|
+
)}
|
|
25
|
+
>
|
|
26
|
+
{items.map((item, i) => {
|
|
27
|
+
const isOpen = open === i;
|
|
28
|
+
const uid = `accordion-${i}`;
|
|
29
|
+
return (
|
|
30
|
+
<div key={uid}>
|
|
31
|
+
<button
|
|
32
|
+
type="button"
|
|
33
|
+
aria-expanded={isOpen}
|
|
34
|
+
aria-controls={`${uid}-panel`}
|
|
35
|
+
onClick={() => setOpen(isOpen ? null : i)}
|
|
36
|
+
className="flex w-full items-center justify-between gap-4 px-5 py-4 text-left text-sm font-semibold text-slate-900 transition-colors hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-lime-500/40"
|
|
37
|
+
>
|
|
38
|
+
{item.title}
|
|
39
|
+
<ChevronDown
|
|
40
|
+
aria-hidden="true"
|
|
41
|
+
className={cn('h-4 w-4 shrink-0 text-slate-400 transition-transform', isOpen && 'rotate-180')}
|
|
42
|
+
/>
|
|
43
|
+
</button>
|
|
44
|
+
{isOpen && (
|
|
45
|
+
<div
|
|
46
|
+
id={`${uid}-panel`}
|
|
47
|
+
role="region"
|
|
48
|
+
className="border-t border-slate-100 px-5 py-4 text-sm leading-relaxed text-slate-600"
|
|
49
|
+
>
|
|
50
|
+
{item.content}
|
|
51
|
+
</div>
|
|
52
|
+
)}
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
})}
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
58
|
}
|
package/src/AlertBanner.tsx
CHANGED
|
@@ -1,62 +1,62 @@
|
|
|
1
|
-
import { Info, AlertTriangle, CheckCircle2, XCircle, X } from 'lucide-react';
|
|
2
|
-
import { useState } from 'react';
|
|
3
|
-
import { cn } from './cn';
|
|
4
|
-
|
|
5
|
-
type AlertTone = 'info' | 'primary' | 'accent' | 'danger' | 'neutral';
|
|
6
|
-
|
|
7
|
-
export interface AlertBannerProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
8
|
-
tone?: AlertTone;
|
|
9
|
-
title?: string;
|
|
10
|
-
dismissible?: boolean;
|
|
11
|
-
onDismiss?: () => void;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
const toneRecord: Record<AlertTone, { wrap: string; icon: React.ReactNode; label: string }> = {
|
|
15
|
-
info: { wrap: 'border-sky-300 bg-sky-50 text-sky-900', icon: <Info className="h-4 w-4" />, label: 'Info' },
|
|
16
|
-
primary: { wrap: 'border-lime-300 bg-lime-50 text-lime-900', icon: <CheckCircle2 className="h-4 w-4" />, label: 'Success' },
|
|
17
|
-
accent: { wrap: 'border-purple-300 bg-purple-50 text-purple-900', icon: <AlertTriangle className="h-4 w-4" />, label: 'Warning' },
|
|
18
|
-
danger: { wrap: 'border-red-300 bg-red-50 text-red-900', icon: <XCircle className="h-4 w-4" />, label: 'Error' },
|
|
19
|
-
neutral: { wrap: 'border-slate-300 bg-slate-50 text-slate-800', icon: <Info className="h-4 w-4" />, label: 'Notice' },
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
export function AlertBanner({
|
|
23
|
-
tone = 'primary',
|
|
24
|
-
title,
|
|
25
|
-
dismissible = false,
|
|
26
|
-
onDismiss,
|
|
27
|
-
className,
|
|
28
|
-
children,
|
|
29
|
-
...props
|
|
30
|
-
}: AlertBannerProps) {
|
|
31
|
-
const [hidden, setHidden] = useState(false);
|
|
32
|
-
if (hidden) return null;
|
|
33
|
-
const t = toneRecord[tone];
|
|
34
|
-
return (
|
|
35
|
-
<div
|
|
36
|
-
role="alert"
|
|
37
|
-
className={cn('flex items-start gap-3 rounded-lg border px-4 py-3 text-sm', t.wrap, className)}
|
|
38
|
-
{...props}
|
|
39
|
-
>
|
|
40
|
-
<span className="mt-0.5 shrink-0" aria-label={t.label}>
|
|
41
|
-
{t.icon}
|
|
42
|
-
</span>
|
|
43
|
-
<div className="min-w-0 flex-1">
|
|
44
|
-
{title && <p className="font-bold">{title}</p>}
|
|
45
|
-
<div className={cn(title && 'mt-0.5 text-sm opacity-90')}>{children}</div>
|
|
46
|
-
</div>
|
|
47
|
-
{dismissible && (
|
|
48
|
-
<button
|
|
49
|
-
type="button"
|
|
50
|
-
aria-label="Dismiss"
|
|
51
|
-
onClick={() => {
|
|
52
|
-
setHidden(true);
|
|
53
|
-
onDismiss?.();
|
|
54
|
-
}}
|
|
55
|
-
className="-mr-1 -mt-1 shrink-0 rounded-full p-1 hover:bg-black/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current"
|
|
56
|
-
>
|
|
57
|
-
<X aria-hidden="true" className="h-4 w-4" />
|
|
58
|
-
</button>
|
|
59
|
-
)}
|
|
60
|
-
</div>
|
|
61
|
-
);
|
|
1
|
+
import { Info, AlertTriangle, CheckCircle2, XCircle, X } from 'lucide-react';
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { cn } from './cn';
|
|
4
|
+
|
|
5
|
+
type AlertTone = 'info' | 'primary' | 'accent' | 'danger' | 'neutral';
|
|
6
|
+
|
|
7
|
+
export interface AlertBannerProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
8
|
+
tone?: AlertTone;
|
|
9
|
+
title?: string;
|
|
10
|
+
dismissible?: boolean;
|
|
11
|
+
onDismiss?: () => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const toneRecord: Record<AlertTone, { wrap: string; icon: React.ReactNode; label: string }> = {
|
|
15
|
+
info: { wrap: 'border-sky-300 bg-sky-50 text-sky-900', icon: <Info className="h-4 w-4" />, label: 'Info' },
|
|
16
|
+
primary: { wrap: 'border-lime-300 bg-lime-50 text-lime-900', icon: <CheckCircle2 className="h-4 w-4" />, label: 'Success' },
|
|
17
|
+
accent: { wrap: 'border-purple-300 bg-purple-50 text-purple-900', icon: <AlertTriangle className="h-4 w-4" />, label: 'Warning' },
|
|
18
|
+
danger: { wrap: 'border-red-300 bg-red-50 text-red-900', icon: <XCircle className="h-4 w-4" />, label: 'Error' },
|
|
19
|
+
neutral: { wrap: 'border-slate-300 bg-slate-50 text-slate-800', icon: <Info className="h-4 w-4" />, label: 'Notice' },
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function AlertBanner({
|
|
23
|
+
tone = 'primary',
|
|
24
|
+
title,
|
|
25
|
+
dismissible = false,
|
|
26
|
+
onDismiss,
|
|
27
|
+
className,
|
|
28
|
+
children,
|
|
29
|
+
...props
|
|
30
|
+
}: AlertBannerProps) {
|
|
31
|
+
const [hidden, setHidden] = useState(false);
|
|
32
|
+
if (hidden) return null;
|
|
33
|
+
const t = toneRecord[tone];
|
|
34
|
+
return (
|
|
35
|
+
<div
|
|
36
|
+
role="alert"
|
|
37
|
+
className={cn('flex items-start gap-3 rounded-lg border px-4 py-3 text-sm', t.wrap, className)}
|
|
38
|
+
{...props}
|
|
39
|
+
>
|
|
40
|
+
<span className="mt-0.5 shrink-0" aria-label={t.label}>
|
|
41
|
+
{t.icon}
|
|
42
|
+
</span>
|
|
43
|
+
<div className="min-w-0 flex-1">
|
|
44
|
+
{title && <p className="font-bold">{title}</p>}
|
|
45
|
+
<div className={cn(title && 'mt-0.5 text-sm opacity-90')}>{children}</div>
|
|
46
|
+
</div>
|
|
47
|
+
{dismissible && (
|
|
48
|
+
<button
|
|
49
|
+
type="button"
|
|
50
|
+
aria-label="Dismiss"
|
|
51
|
+
onClick={() => {
|
|
52
|
+
setHidden(true);
|
|
53
|
+
onDismiss?.();
|
|
54
|
+
}}
|
|
55
|
+
className="-mr-1 -mt-1 shrink-0 rounded-full p-1 hover:bg-black/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current"
|
|
56
|
+
>
|
|
57
|
+
<X aria-hidden="true" className="h-4 w-4" />
|
|
58
|
+
</button>
|
|
59
|
+
)}
|
|
60
|
+
</div>
|
|
61
|
+
);
|
|
62
62
|
}
|
package/src/Badge.tsx
CHANGED
|
@@ -1,27 +1,27 @@
|
|
|
1
|
-
import { cn } from './cn';
|
|
2
|
-
|
|
3
|
-
type BadgeVariant = 'danger' | 'primary' | 'accent' | 'neutral';
|
|
4
|
-
|
|
5
|
-
export interface BadgeProps extends React.HTMLAttributes<HTMLSpanElement> {
|
|
6
|
-
variant?: BadgeVariant;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
const variantClasses: Record<BadgeVariant, string> = {
|
|
10
|
-
danger: 'bg-red-100 text-red-800',
|
|
11
|
-
primary: 'bg-lime-100 text-lime-800',
|
|
12
|
-
accent: 'bg-purple-100 text-purple-900',
|
|
13
|
-
neutral: 'bg-slate-200 text-slate-700',
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
export function Badge({ variant = 'danger', className, ...props }: BadgeProps) {
|
|
17
|
-
return (
|
|
18
|
-
<span
|
|
19
|
-
className={cn(
|
|
20
|
-
'inline-flex items-center justify-center rounded-full text-[10px] font-bold leading-none',
|
|
21
|
-
variantClasses[variant],
|
|
22
|
-
className
|
|
23
|
-
)}
|
|
24
|
-
{...props}
|
|
25
|
-
/>
|
|
26
|
-
);
|
|
27
|
-
}
|
|
1
|
+
import { cn } from './cn';
|
|
2
|
+
|
|
3
|
+
type BadgeVariant = 'danger' | 'primary' | 'accent' | 'neutral';
|
|
4
|
+
|
|
5
|
+
export interface BadgeProps extends React.HTMLAttributes<HTMLSpanElement> {
|
|
6
|
+
variant?: BadgeVariant;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const variantClasses: Record<BadgeVariant, string> = {
|
|
10
|
+
danger: 'bg-red-100 text-red-800',
|
|
11
|
+
primary: 'bg-lime-100 text-lime-800',
|
|
12
|
+
accent: 'bg-purple-100 text-purple-900',
|
|
13
|
+
neutral: 'bg-slate-200 text-slate-700',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function Badge({ variant = 'danger', className, ...props }: BadgeProps) {
|
|
17
|
+
return (
|
|
18
|
+
<span
|
|
19
|
+
className={cn(
|
|
20
|
+
'inline-flex items-center justify-center rounded-full text-[10px] font-bold leading-none',
|
|
21
|
+
variantClasses[variant],
|
|
22
|
+
className
|
|
23
|
+
)}
|
|
24
|
+
{...props}
|
|
25
|
+
/>
|
|
26
|
+
);
|
|
27
|
+
}
|
package/src/BottomSheet.tsx
CHANGED
|
@@ -1,60 +1,60 @@
|
|
|
1
|
-
import { useEffect } from 'react';
|
|
2
|
-
import { createPortal } from 'react-dom';
|
|
3
|
-
import { X } from 'lucide-react';
|
|
4
|
-
import { cn } from './cn';
|
|
5
|
-
|
|
6
|
-
export interface BottomSheetProps {
|
|
7
|
-
open: boolean;
|
|
8
|
-
onClose: () => void;
|
|
9
|
-
title?: string;
|
|
10
|
-
children?: React.ReactNode;
|
|
11
|
-
className?: string;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export function BottomSheet({ open, onClose, title, children, className }: BottomSheetProps) {
|
|
15
|
-
useEffect(() => {
|
|
16
|
-
if (!open) return;
|
|
17
|
-
const onKey = (e: KeyboardEvent) => {
|
|
18
|
-
if (e.key === 'Escape') onClose();
|
|
19
|
-
};
|
|
20
|
-
document.body.style.overflow = 'hidden';
|
|
21
|
-
document.addEventListener('keydown', onKey);
|
|
22
|
-
return () => {
|
|
23
|
-
document.body.style.overflow = '';
|
|
24
|
-
document.removeEventListener('keydown', onKey);
|
|
25
|
-
};
|
|
26
|
-
}, [open, onClose]);
|
|
27
|
-
|
|
28
|
-
if (!open) return null;
|
|
29
|
-
|
|
30
|
-
return createPortal(
|
|
31
|
-
<div className="fixed inset-0 z-50 flex items-end justify-center">
|
|
32
|
-
<div className="absolute inset-0 bg-black/50" onClick={onClose} aria-hidden="true" />
|
|
33
|
-
<section
|
|
34
|
-
role="dialog"
|
|
35
|
-
aria-modal="true"
|
|
36
|
-
aria-label={title}
|
|
37
|
-
className={cn(
|
|
38
|
-
'relative w-full max-w-lg rounded-t-2xl bg-white p-6 shadow-xl',
|
|
39
|
-
className
|
|
40
|
-
)}
|
|
41
|
-
>
|
|
42
|
-
<div aria-hidden="true" className="mx-auto mb-4 h-1 w-10 rounded-full bg-slate-200" />
|
|
43
|
-
{title && (
|
|
44
|
-
<div className="mb-2 flex items-center justify-between">
|
|
45
|
-
<h2 className="text-lg font-bold text-slate-900">{title}</h2>
|
|
46
|
-
<button
|
|
47
|
-
onClick={onClose}
|
|
48
|
-
className="rounded-lg p-1 text-slate-400 hover:bg-slate-100"
|
|
49
|
-
aria-label="Close"
|
|
50
|
-
>
|
|
51
|
-
<X className="h-5 w-5" />
|
|
52
|
-
</button>
|
|
53
|
-
</div>
|
|
54
|
-
)}
|
|
55
|
-
{children}
|
|
56
|
-
</section>
|
|
57
|
-
</div>,
|
|
58
|
-
document.body
|
|
59
|
-
);
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
3
|
+
import { X } from 'lucide-react';
|
|
4
|
+
import { cn } from './cn';
|
|
5
|
+
|
|
6
|
+
export interface BottomSheetProps {
|
|
7
|
+
open: boolean;
|
|
8
|
+
onClose: () => void;
|
|
9
|
+
title?: string;
|
|
10
|
+
children?: React.ReactNode;
|
|
11
|
+
className?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function BottomSheet({ open, onClose, title, children, className }: BottomSheetProps) {
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
if (!open) return;
|
|
17
|
+
const onKey = (e: KeyboardEvent) => {
|
|
18
|
+
if (e.key === 'Escape') onClose();
|
|
19
|
+
};
|
|
20
|
+
document.body.style.overflow = 'hidden';
|
|
21
|
+
document.addEventListener('keydown', onKey);
|
|
22
|
+
return () => {
|
|
23
|
+
document.body.style.overflow = '';
|
|
24
|
+
document.removeEventListener('keydown', onKey);
|
|
25
|
+
};
|
|
26
|
+
}, [open, onClose]);
|
|
27
|
+
|
|
28
|
+
if (!open) return null;
|
|
29
|
+
|
|
30
|
+
return createPortal(
|
|
31
|
+
<div className="fixed inset-0 z-50 flex items-end justify-center">
|
|
32
|
+
<div className="absolute inset-0 bg-black/50" onClick={onClose} aria-hidden="true" />
|
|
33
|
+
<section
|
|
34
|
+
role="dialog"
|
|
35
|
+
aria-modal="true"
|
|
36
|
+
aria-label={title}
|
|
37
|
+
className={cn(
|
|
38
|
+
'relative w-full max-w-lg rounded-t-2xl bg-white p-6 shadow-xl',
|
|
39
|
+
className
|
|
40
|
+
)}
|
|
41
|
+
>
|
|
42
|
+
<div aria-hidden="true" className="mx-auto mb-4 h-1 w-10 rounded-full bg-slate-200" />
|
|
43
|
+
{title && (
|
|
44
|
+
<div className="mb-2 flex items-center justify-between">
|
|
45
|
+
<h2 className="text-lg font-bold text-slate-900">{title}</h2>
|
|
46
|
+
<button
|
|
47
|
+
onClick={onClose}
|
|
48
|
+
className="rounded-lg p-1 text-slate-400 hover:bg-slate-100"
|
|
49
|
+
aria-label="Close"
|
|
50
|
+
>
|
|
51
|
+
<X className="h-5 w-5" />
|
|
52
|
+
</button>
|
|
53
|
+
</div>
|
|
54
|
+
)}
|
|
55
|
+
{children}
|
|
56
|
+
</section>
|
|
57
|
+
</div>,
|
|
58
|
+
document.body
|
|
59
|
+
);
|
|
60
60
|
}
|
package/src/Button.tsx
CHANGED
|
@@ -1,54 +1,54 @@
|
|
|
1
|
-
import { cn } from './cn';
|
|
2
|
-
import { Spinner } from './Spinner';
|
|
3
|
-
|
|
4
|
-
type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost';
|
|
5
|
-
type ButtonSize = 'sm' | 'md' | 'lg' | 'full';
|
|
6
|
-
|
|
7
|
-
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
8
|
-
variant?: ButtonVariant;
|
|
9
|
-
size?: ButtonSize;
|
|
10
|
-
loading?: boolean;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
const variantClasses: Record<ButtonVariant, string> = {
|
|
14
|
-
primary:
|
|
15
|
-
'bg-lime-600 text-white hover:bg-lime-700 focus-visible:ring-lime-500/40',
|
|
16
|
-
secondary:
|
|
17
|
-
'border border-slate-300 bg-white text-slate-700 hover:bg-slate-50 focus-visible:ring-slate-400/40',
|
|
18
|
-
danger: 'bg-red-600 text-white hover:bg-red-700 focus-visible:ring-red-500/40',
|
|
19
|
-
ghost:
|
|
20
|
-
'bg-transparent text-slate-600 hover:bg-slate-100 focus-visible:ring-slate-400/40',
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
const sizeClasses: Record<ButtonSize, string> = {
|
|
24
|
-
sm: 'px-3 py-1.5 text-xs',
|
|
25
|
-
md: 'px-4 py-2.5 text-sm',
|
|
26
|
-
lg: 'px-6 py-3 text-base',
|
|
27
|
-
full: 'w-full px-4 py-2.5 text-sm',
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
export function Button({
|
|
31
|
-
variant = 'primary',
|
|
32
|
-
size = 'md',
|
|
33
|
-
loading = false,
|
|
34
|
-
className,
|
|
35
|
-
disabled,
|
|
36
|
-
children,
|
|
37
|
-
...props
|
|
38
|
-
}: ButtonProps) {
|
|
39
|
-
return (
|
|
40
|
-
<button
|
|
41
|
-
className={cn(
|
|
42
|
-
'inline-flex items-center justify-center gap-2 rounded-lg font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-50',
|
|
43
|
-
variantClasses[variant],
|
|
44
|
-
sizeClasses[size],
|
|
45
|
-
className
|
|
46
|
-
)}
|
|
47
|
-
disabled={disabled || loading}
|
|
48
|
-
{...props}
|
|
49
|
-
>
|
|
50
|
-
{loading && <Spinner className="h-4 w-4" />}
|
|
51
|
-
{children}
|
|
52
|
-
</button>
|
|
53
|
-
);
|
|
54
|
-
}
|
|
1
|
+
import { cn } from './cn';
|
|
2
|
+
import { Spinner } from './Spinner';
|
|
3
|
+
|
|
4
|
+
type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost';
|
|
5
|
+
type ButtonSize = 'sm' | 'md' | 'lg' | 'full';
|
|
6
|
+
|
|
7
|
+
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
8
|
+
variant?: ButtonVariant;
|
|
9
|
+
size?: ButtonSize;
|
|
10
|
+
loading?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const variantClasses: Record<ButtonVariant, string> = {
|
|
14
|
+
primary:
|
|
15
|
+
'bg-lime-600 text-white hover:bg-lime-700 focus-visible:ring-lime-500/40',
|
|
16
|
+
secondary:
|
|
17
|
+
'border border-slate-300 bg-white text-slate-700 hover:bg-slate-50 focus-visible:ring-slate-400/40',
|
|
18
|
+
danger: 'bg-red-600 text-white hover:bg-red-700 focus-visible:ring-red-500/40',
|
|
19
|
+
ghost:
|
|
20
|
+
'bg-transparent text-slate-600 hover:bg-slate-100 focus-visible:ring-slate-400/40',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const sizeClasses: Record<ButtonSize, string> = {
|
|
24
|
+
sm: 'px-3 py-1.5 text-xs',
|
|
25
|
+
md: 'px-4 py-2.5 text-sm',
|
|
26
|
+
lg: 'px-6 py-3 text-base',
|
|
27
|
+
full: 'w-full px-4 py-2.5 text-sm',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function Button({
|
|
31
|
+
variant = 'primary',
|
|
32
|
+
size = 'md',
|
|
33
|
+
loading = false,
|
|
34
|
+
className,
|
|
35
|
+
disabled,
|
|
36
|
+
children,
|
|
37
|
+
...props
|
|
38
|
+
}: ButtonProps) {
|
|
39
|
+
return (
|
|
40
|
+
<button
|
|
41
|
+
className={cn(
|
|
42
|
+
'inline-flex items-center justify-center gap-2 rounded-lg font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-50',
|
|
43
|
+
variantClasses[variant],
|
|
44
|
+
sizeClasses[size],
|
|
45
|
+
className
|
|
46
|
+
)}
|
|
47
|
+
disabled={disabled || loading}
|
|
48
|
+
{...props}
|
|
49
|
+
>
|
|
50
|
+
{loading && <Spinner className="h-4 w-4" />}
|
|
51
|
+
{children}
|
|
52
|
+
</button>
|
|
53
|
+
);
|
|
54
|
+
}
|