domify-ui 1.0.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/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "domify-ui",
3
+ "version": "1.0.0",
4
+ "private": false,
5
+ "description": "Domify - Shared UI Library",
6
+ "author": "Hilthermann Viegas",
7
+ "license": "MIT",
8
+ "keywords": [
9
+ "domify",
10
+ "ui",
11
+ "library"
12
+ ],
13
+ "main": "./src/index.ts",
14
+ "types": "./src/index.ts",
15
+ "peerDependencies": {
16
+ "next": "^15.1.0",
17
+ "react": "^19.0.0",
18
+ "react-dom": "^19.0.0"
19
+ },
20
+ "dependencies": {
21
+ "clsx": "^2.1.1",
22
+ "lucide-react": "^0.460.0",
23
+ "tailwind-merge": "^2.5.4"
24
+ }
25
+ }
package/src/button.tsx ADDED
@@ -0,0 +1,47 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { cn } from "./utils";
5
+
6
+ export interface ButtonProps
7
+ extends React.ButtonHTMLAttributes<HTMLButtonElement> {
8
+ variant?: "default" | "secondary" | "destructive" | "outline" | "ghost";
9
+ size?: "default" | "sm" | "lg";
10
+ }
11
+
12
+ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
13
+ ({ className, variant = "default", size = "default", ...props }, ref) => {
14
+ return (
15
+ <button
16
+ className={cn(
17
+ "inline-flex items-center justify-center text-sm transition-all",
18
+ "font-medium rounded-[10px]",
19
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-[#0052CC]",
20
+ "disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed",
21
+ {
22
+ "bg-[#0052CC] text-white hover:bg-[#003d99] active:bg-[#002d73] shadow-sm hover:shadow-md":
23
+ variant === "default",
24
+ "bg-[#FF7A00] text-white hover:bg-[#e66d00] active:bg-[#cc6000] shadow-sm hover:shadow-md":
25
+ variant === "secondary",
26
+ "bg-red-600 text-white hover:bg-red-700 active:bg-red-800 shadow-sm hover:shadow-md":
27
+ variant === "destructive",
28
+ "border-2 border-[#0052CC] bg-white text-[#0052CC] hover:bg-[#f0f5ff] hover:border-[#003d99]":
29
+ variant === "outline",
30
+ "text-[#4A4A4A] hover:bg-gray-100 active:bg-gray-200":
31
+ variant === "ghost",
32
+ },
33
+ {
34
+ "h-11 px-5 py-2.5": size === "default",
35
+ "h-9 px-3 py-2 text-xs": size === "sm",
36
+ "h-12 px-8 py-3": size === "lg",
37
+ },
38
+ className,
39
+ )}
40
+ ref={ref}
41
+ {...props}
42
+ />
43
+ );
44
+ },
45
+ );
46
+
47
+ Button.displayName = "Button";
@@ -0,0 +1,36 @@
1
+ import * as React from "react";
2
+ import { cn } from "./utils";
3
+
4
+ export interface CheckboxProps
5
+ extends React.InputHTMLAttributes<HTMLInputElement> {
6
+ label?: string;
7
+ }
8
+
9
+ export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
10
+ ({ className, label, ...props }, ref) => {
11
+ return (
12
+ <label className="flex items-center gap-2 cursor-pointer">
13
+ <input
14
+ type="checkbox"
15
+ className={cn(
16
+ "w-5 h-5 rounded border-2 border-gray-300 text-blue-600 transition-all",
17
+ "hover:border-gray-400",
18
+ "focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2",
19
+ "disabled:cursor-not-allowed disabled:opacity-50",
20
+ "cursor-pointer",
21
+ className,
22
+ )}
23
+ ref={ref}
24
+ {...props}
25
+ />
26
+ {label ? (
27
+ <span className="text-sm font-medium text-gray-700 select-none">
28
+ {label}
29
+ </span>
30
+ ) : null}
31
+ </label>
32
+ );
33
+ },
34
+ );
35
+
36
+ Checkbox.displayName = "Checkbox";
@@ -0,0 +1,72 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { Input, InputProps } from "./input";
5
+ import { cn } from "./utils";
6
+ import { maskCurrency, unmaskCurrency } from "./masks";
7
+
8
+ interface CurrencyInputProps extends Omit<InputProps, "type" | "value" | "onChange"> {
9
+ value?: number | string;
10
+ onChange?: (value: number) => void;
11
+ error?: boolean;
12
+ }
13
+
14
+ export const CurrencyInput = React.forwardRef<HTMLInputElement, CurrencyInputProps>(
15
+ ({ className, value, onChange, error, ...props }, ref) => {
16
+ const [displayValue, setDisplayValue] = React.useState<string>("");
17
+
18
+ React.useEffect(() => {
19
+ if (value === undefined || value === null || value === "" || value === 0) {
20
+ setDisplayValue("");
21
+ return;
22
+ }
23
+ const numValue = typeof value === "string" ? parseFloat(value) : value;
24
+ setDisplayValue(!isNaN(numValue) && numValue > 0 ? maskCurrency(numValue) : "");
25
+ }, [value]);
26
+
27
+ const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
28
+ const inputValue = e.target.value;
29
+ const cleaned = inputValue.replace(/[^\d,.-]/g, "");
30
+
31
+ if (cleaned === "" || cleaned.replace(/\D/g, "") === "") {
32
+ setDisplayValue("");
33
+ onChange?.(0);
34
+ return;
35
+ }
36
+
37
+ setDisplayValue(cleaned);
38
+ onChange?.(unmaskCurrency(cleaned));
39
+ };
40
+
41
+ const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
42
+ const cleaned = e.target.value.replace(/[^\d,.-]/g, "");
43
+ if (cleaned === "" || cleaned.replace(/\D/g, "") === "") {
44
+ setDisplayValue("");
45
+ onChange?.(0);
46
+ } else {
47
+ const numericValue = unmaskCurrency(cleaned);
48
+ setDisplayValue(maskCurrency(numericValue));
49
+ onChange?.(numericValue);
50
+ }
51
+ props.onBlur?.(e);
52
+ };
53
+
54
+ return (
55
+ <div className="relative">
56
+ <span className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500 text-sm">R$</span>
57
+ <Input
58
+ {...props}
59
+ ref={ref}
60
+ type="text"
61
+ value={displayValue}
62
+ onChange={handleChange}
63
+ onBlur={handleBlur}
64
+ className={cn("pl-10", error && "border-red-500 focus:ring-red-500 focus:border-red-500", className)}
65
+ placeholder="0,00"
66
+ />
67
+ </div>
68
+ );
69
+ },
70
+ );
71
+
72
+ CurrencyInput.displayName = "CurrencyInput";
package/src/index.ts ADDED
@@ -0,0 +1,12 @@
1
+ export * from "./button";
2
+ export * from "./input";
3
+ export * from "./select";
4
+ export * from "./textarea";
5
+ export * from "./checkbox";
6
+ export * from "./switch";
7
+ export * from "./tabs";
8
+ export * from "./pagination";
9
+ export * from "./skeleton";
10
+ export * from "./searchable-select";
11
+ export * from "./currency-input";
12
+ export * from "./logo";
package/src/input.tsx ADDED
@@ -0,0 +1,26 @@
1
+ import * as React from "react";
2
+ import { cn } from "./utils";
3
+
4
+ export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
5
+
6
+ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
7
+ ({ className, type, ...props }, ref) => {
8
+ return (
9
+ <input
10
+ type={type}
11
+ className={cn(
12
+ "flex h-11 w-full rounded-lg border-2 border-gray-300 bg-white px-4 py-2.5 text-sm text-[#0B2346] placeholder:text-[#888888] transition-all",
13
+ "hover:border-gray-400",
14
+ "focus:outline-none focus:ring-2 focus:ring-[#0052CC] focus:border-[#0052CC]",
15
+ "disabled:cursor-not-allowed disabled:opacity-50 disabled:bg-gray-50",
16
+ className,
17
+ )}
18
+ style={{ fontFamily: "Inter, sans-serif" }}
19
+ ref={ref}
20
+ {...props}
21
+ />
22
+ );
23
+ },
24
+ );
25
+
26
+ Input.displayName = "Input";
package/src/logo.tsx ADDED
@@ -0,0 +1,50 @@
1
+ import * as React from "react";
2
+ import Image from "next/image";
3
+ import { cn } from "./utils";
4
+
5
+ export interface LogoProps extends React.HTMLAttributes<HTMLDivElement> {
6
+ size?: "sm" | "md" | "lg" | "xl";
7
+ showTagline?: boolean;
8
+ variant?: "blue" | "orange" | "white" | "black";
9
+ }
10
+
11
+ const sizeClasses = {
12
+ sm: { width: 100, height: 30 },
13
+ md: { width: 140, height: 42 },
14
+ lg: { width: 180, height: 54 },
15
+ xl: { width: 220, height: 66 },
16
+ };
17
+
18
+ export const DomifyLogo = React.forwardRef<HTMLDivElement, LogoProps>(
19
+ ({ className, size = "md", showTagline = false, variant = "orange", ...props }, ref) => {
20
+ const taglineColor = variant === "orange" ? "text-[#FF7A00]" : "text-gray-300";
21
+ const logoSrc = `/logo_${variant}.png`;
22
+ const dimensions = sizeClasses[size];
23
+
24
+ return (
25
+ <div ref={ref} className={cn("inline-block", className)} {...props}>
26
+ <Image
27
+ src={logoSrc}
28
+ alt="Domify"
29
+ width={dimensions.width}
30
+ height={dimensions.height}
31
+ priority
32
+ className="object-contain"
33
+ style={{ width: "auto", height: "auto" }}
34
+ />
35
+ {showTagline ? (
36
+ <p
37
+ className={cn("text-xs mt-1", taglineColor, {
38
+ "mt-0.5": size === "sm",
39
+ })}
40
+ style={{ fontFamily: "Inter", fontWeight: 300 }}
41
+ >
42
+ Gestao Inteligente de Imoveis
43
+ </p>
44
+ ) : null}
45
+ </div>
46
+ );
47
+ },
48
+ );
49
+
50
+ DomifyLogo.displayName = "DomifyLogo";
package/src/masks.ts ADDED
@@ -0,0 +1,38 @@
1
+ export function maskCurrency(value: string | number): string {
2
+ const numValue =
3
+ typeof value === "string"
4
+ ? parseFloat(value.replace(/\D/g, "")) / 100
5
+ : typeof value === "number"
6
+ ? value
7
+ : 0;
8
+
9
+ if (isNaN(numValue) || numValue === 0) return "";
10
+
11
+ return new Intl.NumberFormat("pt-BR", {
12
+ minimumFractionDigits: 2,
13
+ maximumFractionDigits: 2,
14
+ }).format(numValue);
15
+ }
16
+
17
+ export function unmaskCurrency(value: string): number {
18
+ if (!value || value.trim() === "") return 0;
19
+
20
+ const cleaned = value.replace(/[^\d,.-]/g, "");
21
+ if (cleaned === "") return 0;
22
+
23
+ if (cleaned.includes(",")) {
24
+ const normalized = cleaned.replace(/\./g, "").replace(",", ".");
25
+ const numValue = parseFloat(normalized);
26
+ return isNaN(numValue) ? 0 : numValue;
27
+ }
28
+
29
+ const parts = cleaned.split(".");
30
+ if (parts.length === 2 && parts[1].length <= 2) {
31
+ return parseFloat(cleaned) || 0;
32
+ }
33
+ if (parts.length > 1) {
34
+ return parseFloat(cleaned.replace(/\./g, "")) || 0;
35
+ }
36
+
37
+ return parseFloat(cleaned) || 0;
38
+ }
@@ -0,0 +1,161 @@
1
+ "use client";
2
+
3
+ import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react";
4
+ import { Button } from "./button";
5
+
6
+ interface PaginationProps {
7
+ currentPage: number;
8
+ totalPages: number;
9
+ totalItems: number;
10
+ perPage: number;
11
+ onPageChange: (page: number) => void;
12
+ className?: string;
13
+ }
14
+
15
+ export function Pagination({
16
+ currentPage,
17
+ totalPages,
18
+ totalItems,
19
+ perPage,
20
+ onPageChange,
21
+ className = "",
22
+ }: PaginationProps) {
23
+ const startItem = (currentPage - 1) * perPage + 1;
24
+ const endItem = Math.min(currentPage * perPage, totalItems);
25
+
26
+ const getPageNumbers = () => {
27
+ const pages: (number | string)[] = [];
28
+ const maxVisiblePages = 5;
29
+
30
+ if (totalPages <= maxVisiblePages) {
31
+ for (let i = 1; i <= totalPages; i++) pages.push(i);
32
+ return pages;
33
+ }
34
+
35
+ pages.push(1);
36
+ if (currentPage > 3) pages.push("...");
37
+
38
+ const start = Math.max(2, currentPage - 1);
39
+ const end = Math.min(totalPages - 1, currentPage + 1);
40
+ for (let i = start; i <= end; i++) {
41
+ if (!pages.includes(i)) pages.push(i);
42
+ }
43
+
44
+ if (currentPage < totalPages - 2) pages.push("...");
45
+ if (!pages.includes(totalPages)) pages.push(totalPages);
46
+
47
+ return pages;
48
+ };
49
+
50
+ if (totalPages <= 1) {
51
+ return (
52
+ <div className={`flex items-center justify-center text-sm text-[#888888] ${className}`}>
53
+ Mostrando {totalItems} de {totalItems} {totalItems === 1 ? "item" : "itens"}
54
+ </div>
55
+ );
56
+ }
57
+
58
+ return (
59
+ <div className={`flex flex-col sm:flex-row items-center justify-between gap-4 ${className}`}>
60
+ <div className="text-sm text-[#888888]" style={{ fontFamily: "Inter" }}>
61
+ Mostrando {startItem} a {endItem} de {totalItems} {totalItems === 1 ? "item" : "itens"}
62
+ </div>
63
+
64
+ <div className="flex items-center gap-1">
65
+ <Button
66
+ variant="outline"
67
+ size="sm"
68
+ onClick={() => onPageChange(1)}
69
+ disabled={currentPage === 1}
70
+ className="hidden sm:flex"
71
+ >
72
+ <ChevronsLeft size={16} />
73
+ </Button>
74
+
75
+ <Button
76
+ variant="outline"
77
+ size="sm"
78
+ onClick={() => onPageChange(currentPage - 1)}
79
+ disabled={currentPage === 1}
80
+ >
81
+ <ChevronLeft size={16} />
82
+ </Button>
83
+
84
+ <div className="flex items-center gap-1">
85
+ {getPageNumbers().map((page, index) =>
86
+ page === "..." ? (
87
+ <span key={`ellipsis-${index}`} className="px-2 text-[#888888]">
88
+ ...
89
+ </span>
90
+ ) : (
91
+ <Button
92
+ key={page}
93
+ variant={currentPage === page ? "default" : "outline"}
94
+ size="sm"
95
+ onClick={() => onPageChange(page as number)}
96
+ className="min-w-[36px]"
97
+ >
98
+ {page}
99
+ </Button>
100
+ ),
101
+ )}
102
+ </div>
103
+
104
+ <Button
105
+ variant="outline"
106
+ size="sm"
107
+ onClick={() => onPageChange(currentPage + 1)}
108
+ disabled={currentPage === totalPages}
109
+ >
110
+ <ChevronRight size={16} />
111
+ </Button>
112
+
113
+ <Button
114
+ variant="outline"
115
+ size="sm"
116
+ onClick={() => onPageChange(totalPages)}
117
+ disabled={currentPage === totalPages}
118
+ className="hidden sm:flex"
119
+ >
120
+ <ChevronsRight size={16} />
121
+ </Button>
122
+ </div>
123
+ </div>
124
+ );
125
+ }
126
+
127
+ export function PaginationCompact({
128
+ currentPage,
129
+ totalPages,
130
+ onPageChange,
131
+ }: Pick<PaginationProps, "currentPage" | "totalPages" | "onPageChange">) {
132
+ if (totalPages <= 1) return null;
133
+
134
+ return (
135
+ <div className="flex items-center justify-center gap-4">
136
+ <Button
137
+ variant="outline"
138
+ size="sm"
139
+ onClick={() => onPageChange(currentPage - 1)}
140
+ disabled={currentPage === 1}
141
+ >
142
+ <ChevronLeft size={16} />
143
+ Anterior
144
+ </Button>
145
+
146
+ <span className="text-sm text-[#888888]" style={{ fontFamily: "Inter" }}>
147
+ Pagina {currentPage} de {totalPages}
148
+ </span>
149
+
150
+ <Button
151
+ variant="outline"
152
+ size="sm"
153
+ onClick={() => onPageChange(currentPage + 1)}
154
+ disabled={currentPage === totalPages}
155
+ >
156
+ Proxima
157
+ <ChevronRight size={16} />
158
+ </Button>
159
+ </div>
160
+ );
161
+ }
@@ -0,0 +1,179 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { Search, ChevronDown } from "lucide-react";
5
+ import { cn } from "./utils";
6
+
7
+ export interface SearchableSelectOption {
8
+ id: number | string;
9
+ label: string;
10
+ [key: string]: unknown;
11
+ }
12
+
13
+ export interface SearchableSelectProps {
14
+ options: SearchableSelectOption[];
15
+ value?: number | string | null;
16
+ onChange?: (value: number | string | null) => void;
17
+ onBlur?: () => void;
18
+ placeholder?: string;
19
+ disabled?: boolean;
20
+ className?: string;
21
+ getOptionLabel?: (option: SearchableSelectOption) => string;
22
+ filterFunction?: (option: SearchableSelectOption, searchTerm: string) => boolean;
23
+ emptyMessage?: string;
24
+ name?: string;
25
+ }
26
+
27
+ export const SearchableSelect = React.forwardRef<
28
+ HTMLInputElement,
29
+ SearchableSelectProps
30
+ >(
31
+ (
32
+ {
33
+ options,
34
+ value,
35
+ onChange,
36
+ onBlur,
37
+ placeholder = "Selecione uma opcao...",
38
+ disabled = false,
39
+ className,
40
+ getOptionLabel = (option) => option.label,
41
+ filterFunction,
42
+ emptyMessage = "Nenhuma opcao encontrada",
43
+ name,
44
+ },
45
+ ref,
46
+ ) => {
47
+ const [isOpen, setIsOpen] = React.useState(false);
48
+ const [searchTerm, setSearchTerm] = React.useState("");
49
+ const containerRef = React.useRef<HTMLDivElement>(null);
50
+ const inputRef = React.useRef<HTMLInputElement>(null);
51
+ const listRef = React.useRef<HTMLUListElement>(null);
52
+
53
+ const selectedOption = React.useMemo(
54
+ () => options.find((opt) => opt.id === value) || null,
55
+ [options, value],
56
+ );
57
+
58
+ const filteredOptions = React.useMemo(() => {
59
+ if (!searchTerm.trim()) return options;
60
+ const term = searchTerm.toLowerCase().trim();
61
+ if (filterFunction) return options.filter((option) => filterFunction(option, term));
62
+ return options.filter((option) => getOptionLabel(option).toLowerCase().includes(term));
63
+ }, [options, searchTerm, getOptionLabel, filterFunction]);
64
+
65
+ React.useEffect(() => {
66
+ const handleClickOutside = (event: MouseEvent) => {
67
+ if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
68
+ setIsOpen(false);
69
+ setSearchTerm("");
70
+ }
71
+ };
72
+
73
+ if (!isOpen) return;
74
+ document.addEventListener("mousedown", handleClickOutside);
75
+ return () => document.removeEventListener("mousedown", handleClickOutside);
76
+ }, [isOpen]);
77
+
78
+ React.useEffect(() => {
79
+ if (!isOpen || !listRef.current || !selectedOption) return;
80
+ const selectedIndex = filteredOptions.findIndex((opt) => opt.id === selectedOption.id);
81
+ if (selectedIndex < 0) return;
82
+ const selectedElement = listRef.current.children[selectedIndex] as HTMLElement;
83
+ selectedElement?.scrollIntoView({ block: "nearest" });
84
+ }, [isOpen, selectedOption, filteredOptions]);
85
+
86
+ const handleSelect = (option: SearchableSelectOption) => {
87
+ onChange?.(option.id);
88
+ setIsOpen(false);
89
+ setSearchTerm("");
90
+ onBlur?.();
91
+ };
92
+
93
+ const handleToggle = () => {
94
+ if (disabled) return;
95
+ setIsOpen((prev) => !prev);
96
+ setTimeout(() => inputRef.current?.focus(), 0);
97
+ };
98
+
99
+ const displayValue = selectedOption ? getOptionLabel(selectedOption) : "";
100
+
101
+ return (
102
+ <div ref={containerRef} className={cn("relative w-full", className)}>
103
+ <div
104
+ className={cn(
105
+ "flex h-11 w-full rounded-lg border-2 bg-white px-4 py-2.5 text-sm text-gray-900 transition-all cursor-pointer",
106
+ "hover:border-gray-400",
107
+ "focus-within:outline-none focus-within:ring-2 focus-within:ring-blue-500 focus-within:border-blue-500",
108
+ disabled && "cursor-not-allowed opacity-50 bg-gray-50 hover:border-gray-300",
109
+ !isOpen && "border-gray-300",
110
+ )}
111
+ onClick={handleToggle}
112
+ >
113
+ <div className="flex items-center flex-1 min-w-0">
114
+ {!isOpen && !displayValue ? <span className="text-gray-500">{placeholder}</span> : null}
115
+ {!isOpen && displayValue ? <span className="truncate">{displayValue}</span> : null}
116
+ {isOpen ? (
117
+ <input
118
+ ref={inputRef}
119
+ type="text"
120
+ value={searchTerm}
121
+ onChange={(e) => {
122
+ if (disabled) return;
123
+ setSearchTerm(e.target.value);
124
+ setIsOpen(true);
125
+ }}
126
+ placeholder={placeholder}
127
+ className="flex-1 outline-none bg-transparent text-gray-900 placeholder:text-gray-500"
128
+ onClick={(e) => e.stopPropagation()}
129
+ disabled={disabled}
130
+ />
131
+ ) : null}
132
+ </div>
133
+
134
+ <div className="flex items-center gap-2 ml-2">
135
+ <Search size={18} className={cn("text-gray-500 transition-transform", isOpen && "rotate-90")} />
136
+ <ChevronDown
137
+ size={18}
138
+ className={cn("text-gray-500 transition-transform", isOpen && "rotate-180")}
139
+ />
140
+ </div>
141
+ </div>
142
+
143
+ {isOpen ? (
144
+ <div className="absolute z-50 w-full mt-1 bg-white border-2 border-gray-300 rounded-lg shadow-lg max-h-60 overflow-hidden">
145
+ <ul ref={listRef} className="overflow-y-auto max-h-60 py-1" role="listbox">
146
+ {filteredOptions.length === 0 ? (
147
+ <li className="px-4 py-3 text-sm text-gray-500 text-center">{emptyMessage}</li>
148
+ ) : (
149
+ filteredOptions.map((option) => {
150
+ const isSelected = option.id === value;
151
+ const label = getOptionLabel(option);
152
+ return (
153
+ <li
154
+ key={option.id}
155
+ role="option"
156
+ aria-selected={isSelected}
157
+ className={cn(
158
+ "px-4 py-2 cursor-pointer text-sm transition-colors",
159
+ "hover:bg-blue-50 hover:text-blue-900",
160
+ isSelected && "bg-blue-100 text-blue-900 font-medium",
161
+ )}
162
+ onClick={() => handleSelect(option)}
163
+ >
164
+ {label}
165
+ </li>
166
+ );
167
+ })
168
+ )}
169
+ </ul>
170
+ </div>
171
+ ) : null}
172
+
173
+ {name ? <input ref={ref} type="hidden" name={name} value={value || ""} readOnly /> : null}
174
+ </div>
175
+ );
176
+ },
177
+ );
178
+
179
+ SearchableSelect.displayName = "SearchableSelect";
package/src/select.tsx ADDED
@@ -0,0 +1,24 @@
1
+ import * as React from "react";
2
+ import { cn } from "./utils";
3
+
4
+ export type SelectProps = React.SelectHTMLAttributes<HTMLSelectElement>;
5
+
6
+ export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
7
+ ({ className, ...props }, ref) => {
8
+ return (
9
+ <select
10
+ className={cn(
11
+ "flex h-11 w-full rounded-lg border-2 border-gray-300 bg-white px-4 py-2.5 text-sm text-gray-900 transition-all",
12
+ "hover:border-gray-400",
13
+ "focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500",
14
+ "disabled:cursor-not-allowed disabled:opacity-50 disabled:bg-gray-50",
15
+ className,
16
+ )}
17
+ ref={ref}
18
+ {...props}
19
+ />
20
+ );
21
+ },
22
+ );
23
+
24
+ Select.displayName = "Select";
@@ -0,0 +1,191 @@
1
+ "use client";
2
+
3
+ import { cn } from "./utils";
4
+
5
+ interface SkeletonProps {
6
+ className?: string;
7
+ }
8
+
9
+ export function Skeleton({ className }: SkeletonProps) {
10
+ return <div className={cn("animate-pulse rounded-md bg-gray-200", className)} />;
11
+ }
12
+
13
+ export function PropertyCardSkeleton() {
14
+ return (
15
+ <div className="bg-white rounded-lg shadow overflow-hidden">
16
+ <Skeleton className="h-48 w-full rounded-none" />
17
+ <div className="p-4 space-y-3">
18
+ <Skeleton className="h-4 w-1/4" />
19
+ <Skeleton className="h-6 w-3/4" />
20
+ <Skeleton className="h-4 w-1/2" />
21
+ <div className="flex gap-4 pt-2">
22
+ <Skeleton className="h-4 w-16" />
23
+ <Skeleton className="h-4 w-16" />
24
+ <Skeleton className="h-4 w-16" />
25
+ </div>
26
+ <div className="flex justify-between pt-4">
27
+ <Skeleton className="h-6 w-24" />
28
+ <Skeleton className="h-8 w-20" />
29
+ </div>
30
+ </div>
31
+ </div>
32
+ );
33
+ }
34
+
35
+ export function TableRowSkeleton({ columns = 5 }: { columns?: number }) {
36
+ return (
37
+ <tr className="border-b border-gray-100">
38
+ {Array.from({ length: columns }).map((_, i) => (
39
+ <td key={i} className="px-4 py-3">
40
+ <Skeleton className="h-4 w-full" />
41
+ </td>
42
+ ))}
43
+ </tr>
44
+ );
45
+ }
46
+
47
+ export function TableSkeleton({
48
+ rows = 5,
49
+ columns = 5,
50
+ }: {
51
+ rows?: number;
52
+ columns?: number;
53
+ }) {
54
+ return (
55
+ <div className="bg-white rounded-lg shadow overflow-hidden">
56
+ <div className="overflow-x-auto">
57
+ <table className="w-full">
58
+ <thead className="bg-gray-50">
59
+ <tr>
60
+ {Array.from({ length: columns }).map((_, i) => (
61
+ <th key={i} className="px-4 py-3 text-left">
62
+ <Skeleton className="h-4 w-20" />
63
+ </th>
64
+ ))}
65
+ </tr>
66
+ </thead>
67
+ <tbody>
68
+ {Array.from({ length: rows }).map((_, i) => (
69
+ <TableRowSkeleton key={i} columns={columns} />
70
+ ))}
71
+ </tbody>
72
+ </table>
73
+ </div>
74
+ </div>
75
+ );
76
+ }
77
+
78
+ export function ListItemSkeleton() {
79
+ return (
80
+ <div className="flex items-center gap-4 p-4 bg-white rounded-lg shadow">
81
+ <Skeleton className="h-12 w-12 rounded-full" />
82
+ <div className="flex-1 space-y-2">
83
+ <Skeleton className="h-4 w-1/3" />
84
+ <Skeleton className="h-3 w-1/2" />
85
+ </div>
86
+ <Skeleton className="h-8 w-20" />
87
+ </div>
88
+ );
89
+ }
90
+
91
+ export function StatsCardSkeleton() {
92
+ return (
93
+ <div className="bg-white rounded-lg shadow p-6">
94
+ <div className="flex items-center justify-between">
95
+ <div className="space-y-2">
96
+ <Skeleton className="h-4 w-24" />
97
+ <Skeleton className="h-8 w-16" />
98
+ </div>
99
+ <Skeleton className="h-12 w-12 rounded-full" />
100
+ </div>
101
+ </div>
102
+ );
103
+ }
104
+
105
+ export function FormSkeleton({ fields = 4 }: { fields?: number }) {
106
+ return (
107
+ <div className="bg-white rounded-lg shadow p-6 space-y-6">
108
+ {Array.from({ length: fields }).map((_, i) => (
109
+ <div key={i} className="space-y-2">
110
+ <Skeleton className="h-4 w-24" />
111
+ <Skeleton className="h-10 w-full" />
112
+ </div>
113
+ ))}
114
+ <div className="flex justify-end gap-4 pt-4">
115
+ <Skeleton className="h-10 w-24" />
116
+ <Skeleton className="h-10 w-32" />
117
+ </div>
118
+ </div>
119
+ );
120
+ }
121
+
122
+ export function DetailPageSkeleton() {
123
+ return (
124
+ <div className="space-y-6">
125
+ <div className="flex items-center justify-between">
126
+ <div className="space-y-2">
127
+ <Skeleton className="h-8 w-48" />
128
+ <Skeleton className="h-4 w-32" />
129
+ </div>
130
+ <div className="flex gap-2">
131
+ <Skeleton className="h-10 w-24" />
132
+ <Skeleton className="h-10 w-24" />
133
+ </div>
134
+ </div>
135
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
136
+ <div className="lg:col-span-2 space-y-6">
137
+ <div className="bg-white rounded-lg shadow p-6 space-y-4">
138
+ <Skeleton className="h-6 w-32" />
139
+ <Skeleton className="h-4 w-full" />
140
+ <Skeleton className="h-4 w-full" />
141
+ <Skeleton className="h-4 w-3/4" />
142
+ </div>
143
+ <div className="bg-white rounded-lg shadow p-6 space-y-4">
144
+ <Skeleton className="h-6 w-32" />
145
+ <div className="grid grid-cols-2 gap-4">
146
+ {Array.from({ length: 6 }).map((_, i) => (
147
+ <div key={i} className="space-y-1">
148
+ <Skeleton className="h-4 w-20" />
149
+ <Skeleton className="h-4 w-28" />
150
+ </div>
151
+ ))}
152
+ </div>
153
+ </div>
154
+ </div>
155
+ <div className="space-y-6">
156
+ <div className="bg-white rounded-lg shadow p-6 space-y-4">
157
+ <Skeleton className="h-6 w-24" />
158
+ <Skeleton className="h-8 w-full" />
159
+ <Skeleton className="h-4 w-full" />
160
+ </div>
161
+ </div>
162
+ </div>
163
+ </div>
164
+ );
165
+ }
166
+
167
+ export function DashboardSkeleton() {
168
+ return (
169
+ <div className="space-y-6">
170
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
171
+ {Array.from({ length: 4 }).map((_, i) => (
172
+ <StatsCardSkeleton key={i} />
173
+ ))}
174
+ </div>
175
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
176
+ <div className="bg-white rounded-lg shadow p-6">
177
+ <Skeleton className="h-6 w-32 mb-4" />
178
+ <Skeleton className="h-64 w-full" />
179
+ </div>
180
+ <div className="bg-white rounded-lg shadow p-6">
181
+ <Skeleton className="h-6 w-32 mb-4" />
182
+ <div className="space-y-4">
183
+ {Array.from({ length: 5 }).map((_, i) => (
184
+ <ListItemSkeleton key={i} />
185
+ ))}
186
+ </div>
187
+ </div>
188
+ </div>
189
+ </div>
190
+ );
191
+ }
package/src/switch.tsx ADDED
@@ -0,0 +1,53 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { cn } from "./utils";
5
+
6
+ export interface SwitchProps {
7
+ id?: string;
8
+ checked?: boolean;
9
+ onCheckedChange?: (checked: boolean) => void;
10
+ disabled?: boolean;
11
+ className?: string;
12
+ }
13
+
14
+ export const Switch = React.forwardRef<HTMLButtonElement, SwitchProps>(
15
+ (
16
+ { id, checked = false, onCheckedChange, disabled = false, className },
17
+ ref,
18
+ ) => {
19
+ const handleClick = () => {
20
+ if (!disabled && onCheckedChange) {
21
+ onCheckedChange(!checked);
22
+ }
23
+ };
24
+
25
+ return (
26
+ <button
27
+ type="button"
28
+ id={id}
29
+ role="switch"
30
+ aria-checked={checked}
31
+ disabled={disabled}
32
+ onClick={handleClick}
33
+ ref={ref}
34
+ className={cn(
35
+ "relative inline-flex h-6 w-11 items-center rounded-full transition-colors",
36
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2",
37
+ "disabled:cursor-not-allowed disabled:opacity-50",
38
+ checked ? "bg-[#0052CC]" : "bg-gray-300",
39
+ className,
40
+ )}
41
+ >
42
+ <span
43
+ className={cn(
44
+ "inline-block h-5 w-5 transform rounded-full bg-white shadow-sm transition-transform",
45
+ checked ? "translate-x-6" : "translate-x-0.5",
46
+ )}
47
+ />
48
+ </button>
49
+ );
50
+ },
51
+ );
52
+
53
+ Switch.displayName = "Switch";
package/src/tabs.tsx ADDED
@@ -0,0 +1,121 @@
1
+ import * as React from "react";
2
+ import { cn } from "./utils";
3
+
4
+ interface TabsContextValue {
5
+ value: string;
6
+ onValueChange: (value: string) => void;
7
+ }
8
+
9
+ const TabsContext = React.createContext<TabsContextValue | undefined>(undefined);
10
+
11
+ function useTabsContext() {
12
+ const context = React.useContext(TabsContext);
13
+ if (!context) {
14
+ throw new Error("Tabs components must be used within a Tabs component");
15
+ }
16
+ return context;
17
+ }
18
+
19
+ interface TabsProps extends React.HTMLAttributes<HTMLDivElement> {
20
+ defaultValue: string;
21
+ onValueChange?: (value: string) => void;
22
+ }
23
+
24
+ export function Tabs({
25
+ defaultValue,
26
+ onValueChange,
27
+ children,
28
+ className,
29
+ ...props
30
+ }: TabsProps) {
31
+ const [value, setValue] = React.useState(defaultValue);
32
+
33
+ const handleValueChange = React.useCallback(
34
+ (newValue: string) => {
35
+ setValue(newValue);
36
+ onValueChange?.(newValue);
37
+ },
38
+ [onValueChange],
39
+ );
40
+
41
+ return (
42
+ <TabsContext.Provider value={{ value, onValueChange: handleValueChange }}>
43
+ <div className={cn("w-full", className)} {...props}>
44
+ {children}
45
+ </div>
46
+ </TabsContext.Provider>
47
+ );
48
+ }
49
+
50
+ type TabsListProps = React.HTMLAttributes<HTMLDivElement>;
51
+
52
+ export function TabsList({ children, className, ...props }: TabsListProps) {
53
+ return (
54
+ <div
55
+ className={cn(
56
+ "inline-flex h-10 items-center justify-center rounded-lg bg-gray-100 p-1 text-gray-500",
57
+ className,
58
+ )}
59
+ {...props}
60
+ >
61
+ {children}
62
+ </div>
63
+ );
64
+ }
65
+
66
+ interface TabsTriggerProps
67
+ extends React.ButtonHTMLAttributes<HTMLButtonElement> {
68
+ value: string;
69
+ }
70
+
71
+ export function TabsTrigger({
72
+ value,
73
+ children,
74
+ className,
75
+ ...props
76
+ }: TabsTriggerProps) {
77
+ const { value: selectedValue, onValueChange } = useTabsContext();
78
+ const isSelected = value === selectedValue;
79
+
80
+ return (
81
+ <button
82
+ type="button"
83
+ role="tab"
84
+ aria-selected={isSelected}
85
+ onClick={() => onValueChange(value)}
86
+ className={cn(
87
+ "inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium transition-all",
88
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2",
89
+ "disabled:pointer-events-none disabled:opacity-50",
90
+ isSelected
91
+ ? "bg-white text-gray-900 shadow-sm"
92
+ : "text-gray-600 hover:bg-gray-50 hover:text-gray-900",
93
+ className,
94
+ )}
95
+ {...props}
96
+ >
97
+ {children}
98
+ </button>
99
+ );
100
+ }
101
+
102
+ interface TabsContentProps extends React.HTMLAttributes<HTMLDivElement> {
103
+ value: string;
104
+ }
105
+
106
+ export function TabsContent({
107
+ value,
108
+ children,
109
+ className,
110
+ ...props
111
+ }: TabsContentProps) {
112
+ const { value: selectedValue } = useTabsContext();
113
+
114
+ if (value !== selectedValue) return null;
115
+
116
+ return (
117
+ <div role="tabpanel" className={cn("mt-2", className)} {...props}>
118
+ {children}
119
+ </div>
120
+ );
121
+ }
@@ -0,0 +1,25 @@
1
+ import * as React from "react";
2
+ import { cn } from "./utils";
3
+
4
+ export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
5
+
6
+ export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
7
+ ({ className, ...props }, ref) => {
8
+ return (
9
+ <textarea
10
+ className={cn(
11
+ "flex min-h-[100px] w-full rounded-lg border-2 border-gray-300 bg-white px-4 py-3 text-sm text-gray-900 placeholder:text-gray-500 transition-all",
12
+ "hover:border-gray-400",
13
+ "focus:outline-none focus:ring-2 focus:ring-[#0052CC] focus:border-[#0052CC]",
14
+ "disabled:cursor-not-allowed disabled:opacity-50 disabled:bg-gray-50",
15
+ "resize-y",
16
+ className,
17
+ )}
18
+ ref={ref}
19
+ {...props}
20
+ />
21
+ );
22
+ },
23
+ );
24
+
25
+ Textarea.displayName = "Textarea";
package/src/utils.ts ADDED
@@ -0,0 +1,6 @@
1
+ import { type ClassValue, clsx } from "clsx";
2
+ import { twMerge } from "tailwind-merge";
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs));
6
+ }
@@ -0,0 +1,11 @@
1
+ import type { Config } from "tailwindcss";
2
+
3
+ const config: Config = {
4
+ content: ["./src/**/*.{ts,tsx}"],
5
+ theme: {
6
+ extend: {},
7
+ },
8
+ plugins: [],
9
+ };
10
+
11
+ export default config;
package/tsconfig.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "lib": ["dom", "es2020"],
5
+ "module": "ESNext",
6
+ "moduleResolution": "bundler",
7
+ "jsx": "react-jsx",
8
+ "strict": true,
9
+ "skipLibCheck": true,
10
+ "declaration": true,
11
+ "declarationMap": true,
12
+ "emitDeclarationOnly": true,
13
+ "outDir": "dist",
14
+ "baseUrl": "."
15
+ },
16
+ "include": ["src/**/*"]
17
+ }