@srcroot/ui 0.0.42 → 0.0.43
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
CHANGED
|
@@ -14,6 +14,10 @@ interface CheckboxProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElemen
|
|
|
14
14
|
* Whether the checkbox is disabled
|
|
15
15
|
*/
|
|
16
16
|
disabled?: boolean
|
|
17
|
+
/**
|
|
18
|
+
* The default checked state (for uncontrolled mode)
|
|
19
|
+
*/
|
|
20
|
+
defaultChecked?: boolean
|
|
17
21
|
}
|
|
18
22
|
|
|
19
23
|
/**
|
|
@@ -24,10 +28,18 @@ interface CheckboxProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElemen
|
|
|
24
28
|
* <Checkbox checked={checked} onCheckedChange={setChecked} />
|
|
25
29
|
*/
|
|
26
30
|
const Checkbox = React.forwardRef<HTMLButtonElement, CheckboxProps>(
|
|
27
|
-
({ className, checked = false, onCheckedChange, disabled, ...props }, ref) => {
|
|
31
|
+
({ className, checked: controlledChecked, defaultChecked = false, onCheckedChange, disabled, ...props }, ref) => {
|
|
32
|
+
const [isChecked, setIsChecked] = React.useState(defaultChecked)
|
|
33
|
+
|
|
34
|
+
const checked = controlledChecked !== undefined ? controlledChecked : isChecked
|
|
35
|
+
|
|
28
36
|
const handleClick = () => {
|
|
29
37
|
if (!disabled) {
|
|
30
|
-
|
|
38
|
+
const newChecked = !checked
|
|
39
|
+
if (controlledChecked === undefined) {
|
|
40
|
+
setIsChecked(newChecked)
|
|
41
|
+
}
|
|
42
|
+
onCheckedChange?.(newChecked)
|
|
31
43
|
}
|
|
32
44
|
}
|
|
33
45
|
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { Label } from "@/components/ui/label"
|
|
3
|
+
import { cn } from "@/lib/utils"
|
|
4
|
+
|
|
5
|
+
export interface FormFieldProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
6
|
+
/**
|
|
7
|
+
* The label for the field
|
|
8
|
+
*/
|
|
9
|
+
label?: React.ReactNode
|
|
10
|
+
/**
|
|
11
|
+
* Helper text description
|
|
12
|
+
*/
|
|
13
|
+
description?: React.ReactNode
|
|
14
|
+
/**
|
|
15
|
+
* Error message
|
|
16
|
+
*/
|
|
17
|
+
error?: React.ReactNode
|
|
18
|
+
/**
|
|
19
|
+
* Whether the field is required (shows asterisk)
|
|
20
|
+
*/
|
|
21
|
+
required?: boolean
|
|
22
|
+
/**
|
|
23
|
+
* The ID of the input element, used for label association
|
|
24
|
+
*/
|
|
25
|
+
htmlFor?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* FormField wrapper for consistent label and error placement
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* <FormField
|
|
33
|
+
* label="Email"
|
|
34
|
+
* description="We'll never share your email."
|
|
35
|
+
* error={errors.email}
|
|
36
|
+
* required
|
|
37
|
+
* >
|
|
38
|
+
* <Input placeholder="user@example.com" />
|
|
39
|
+
* </FormField>
|
|
40
|
+
*/
|
|
41
|
+
const FormField = React.forwardRef<HTMLDivElement, FormFieldProps>(
|
|
42
|
+
({ className, label, description, error, required, htmlFor, children, ...props }, ref) => {
|
|
43
|
+
const id = htmlFor || React.useId()
|
|
44
|
+
|
|
45
|
+
// Clone child to inject id and error state if it's a valid React element
|
|
46
|
+
const childWithProps = React.isValidElement(children)
|
|
47
|
+
? React.cloneElement(children as React.ReactElement<any>, {
|
|
48
|
+
id: id,
|
|
49
|
+
error: !!error,
|
|
50
|
+
"aria-describedby": error ? `${id}-error` : description ? `${id}-desc` : undefined,
|
|
51
|
+
})
|
|
52
|
+
: children
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div
|
|
56
|
+
ref={ref}
|
|
57
|
+
className={cn("space-y-2", className)}
|
|
58
|
+
{...props}
|
|
59
|
+
>
|
|
60
|
+
{label && (
|
|
61
|
+
<Label htmlFor={id} required={required}>
|
|
62
|
+
{label}
|
|
63
|
+
</Label>
|
|
64
|
+
)}
|
|
65
|
+
|
|
66
|
+
{childWithProps}
|
|
67
|
+
|
|
68
|
+
{description && !error && (
|
|
69
|
+
<p
|
|
70
|
+
id={`${id}-desc`}
|
|
71
|
+
className="text-[0.8rem] text-muted-foreground"
|
|
72
|
+
>
|
|
73
|
+
{description}
|
|
74
|
+
</p>
|
|
75
|
+
)}
|
|
76
|
+
|
|
77
|
+
{error && (
|
|
78
|
+
<p
|
|
79
|
+
id={`${id}-error`}
|
|
80
|
+
className="text-[0.8rem] font-medium text-destructive"
|
|
81
|
+
>
|
|
82
|
+
{error}
|
|
83
|
+
</p>
|
|
84
|
+
)}
|
|
85
|
+
</div>
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
)
|
|
89
|
+
FormField.displayName = "FormField"
|
|
90
|
+
|
|
91
|
+
export { FormField }
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { cn } from "@/lib/utils"
|
|
3
|
+
|
|
4
|
+
const InputGroup = React.forwardRef<
|
|
5
|
+
HTMLDivElement,
|
|
6
|
+
React.HTMLAttributes<HTMLDivElement> & { error?: boolean }
|
|
7
|
+
>(({ className, children, error, ...props }, ref) => {
|
|
8
|
+
return (
|
|
9
|
+
<div
|
|
10
|
+
ref={ref}
|
|
11
|
+
className={cn(
|
|
12
|
+
"flex w-full items-center rounded-md",
|
|
13
|
+
"focus-within:ring-1 focus-within:ring-ring",
|
|
14
|
+
error && "focus-within:ring-destructive focus-within:border-destructive",
|
|
15
|
+
// First child: rounded-l only, remove right border if not last
|
|
16
|
+
"[&>*:first-child]:rounded-r-none",
|
|
17
|
+
// Last child: rounded-r only, remove left border if not first
|
|
18
|
+
"[&>*:last-child]:rounded-l-none",
|
|
19
|
+
// Middle children: no rounding
|
|
20
|
+
"[&>*:not(:first-child):not(:last-child)]:rounded-none",
|
|
21
|
+
// Negative margin to merge borders
|
|
22
|
+
"[&>*:not(:first-child)]:-ml-px",
|
|
23
|
+
// Bring hovered element to front
|
|
24
|
+
"[&>*:hover]:z-10",
|
|
25
|
+
className
|
|
26
|
+
)}
|
|
27
|
+
{...props}
|
|
28
|
+
>
|
|
29
|
+
{React.Children.map(children, (child) => {
|
|
30
|
+
if (!React.isValidElement(child)) return child
|
|
31
|
+
const element = child as React.ReactElement<{ className?: string, error?: boolean }>
|
|
32
|
+
|
|
33
|
+
// Only pass error to custom components, not DOM elements
|
|
34
|
+
const additionalProps: { className: string; error?: boolean } = {
|
|
35
|
+
className: cn(
|
|
36
|
+
element.props.className,
|
|
37
|
+
"focus-visible:ring-0 focus-visible:ring-offset-0",
|
|
38
|
+
error && "border-destructive focus-visible:ring-destructive"
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (typeof element.type !== "string") {
|
|
43
|
+
additionalProps.error = error
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return React.cloneElement(element, additionalProps)
|
|
47
|
+
})}
|
|
48
|
+
</div>
|
|
49
|
+
)
|
|
50
|
+
})
|
|
51
|
+
InputGroup.displayName = "InputGroup"
|
|
52
|
+
|
|
53
|
+
const InputAddon = React.forwardRef<
|
|
54
|
+
HTMLDivElement,
|
|
55
|
+
React.HTMLAttributes<HTMLDivElement> & { error?: boolean }
|
|
56
|
+
>(({ className, children, error, ...props }, ref) => {
|
|
57
|
+
return (
|
|
58
|
+
<div
|
|
59
|
+
ref={ref}
|
|
60
|
+
className={cn(
|
|
61
|
+
"flex h-9 items-center justify-center rounded-md border border-input bg-muted px-3 text-sm text-muted-foreground shadow-sm",
|
|
62
|
+
error && "border-destructive",
|
|
63
|
+
className
|
|
64
|
+
)}
|
|
65
|
+
{...props}
|
|
66
|
+
>
|
|
67
|
+
{React.Children.map(children, (child: React.ReactNode) => {
|
|
68
|
+
if (!React.isValidElement(child)) return child
|
|
69
|
+
const element = child as React.ReactElement<{ className?: string }>
|
|
70
|
+
return React.cloneElement(element, {
|
|
71
|
+
className: cn(
|
|
72
|
+
element.props.className,
|
|
73
|
+
"focus-visible:ring-0 focus-visible:ring-offset-0"
|
|
74
|
+
),
|
|
75
|
+
})
|
|
76
|
+
})}
|
|
77
|
+
</div>
|
|
78
|
+
)
|
|
79
|
+
})
|
|
80
|
+
InputAddon.displayName = "InputAddon"
|
|
81
|
+
|
|
82
|
+
export { InputGroup, InputAddon }
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
1
3
|
import * as React from "react"
|
|
4
|
+
import { FiEye, FiEyeOff, FiSearch, FiX } from "react-icons/fi"
|
|
5
|
+
|
|
2
6
|
import { cn } from "@/lib/utils"
|
|
3
7
|
|
|
4
8
|
export interface InputProps
|
|
@@ -12,30 +16,107 @@ export interface InputProps
|
|
|
12
16
|
/**
|
|
13
17
|
* Input component with focus states and error styling
|
|
14
18
|
*
|
|
19
|
+
* Supports special handling for:
|
|
20
|
+
* - type="password": Adds show/hide toggle
|
|
21
|
+
* - type="search": Adds search icon and clear button
|
|
22
|
+
*
|
|
15
23
|
* @example
|
|
16
24
|
* // Basic usage
|
|
17
25
|
* <Input placeholder="Enter your email" />
|
|
18
26
|
*
|
|
19
|
-
* //
|
|
20
|
-
* <Input error placeholder="Invalid email" />
|
|
21
|
-
*
|
|
22
|
-
* // With type
|
|
27
|
+
* // Password with toggle
|
|
23
28
|
* <Input type="password" placeholder="Password" />
|
|
29
|
+
*
|
|
30
|
+
* // Search with icon and clear
|
|
31
|
+
* <Input type="search" placeholder="Search..." />
|
|
24
32
|
*/
|
|
25
33
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|
26
34
|
({ className, type, error, ...props }, ref) => {
|
|
35
|
+
const [isVisible, setIsVisible] = React.useState(false)
|
|
36
|
+
const [value, setValue] = React.useState(props.value || props.defaultValue || "")
|
|
37
|
+
const isPassword = type === "password"
|
|
38
|
+
const isSearch = type === "search"
|
|
39
|
+
|
|
40
|
+
// Handle value changes for search clear functionality
|
|
41
|
+
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
42
|
+
setValue(e.target.value)
|
|
43
|
+
props.onChange?.(e)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const handleClear = () => {
|
|
47
|
+
setValue("")
|
|
48
|
+
// Create a synthetic event to notify parent
|
|
49
|
+
const event = {
|
|
50
|
+
target: { value: "" },
|
|
51
|
+
currentTarget: { value: "" },
|
|
52
|
+
} as React.ChangeEvent<HTMLInputElement>
|
|
53
|
+
props.onChange?.(event)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Base input styles
|
|
57
|
+
const baseStyles = cn(
|
|
58
|
+
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
|
59
|
+
error && "border-destructive focus-visible:ring-destructive",
|
|
60
|
+
isSearch && "pl-9", // Add padding for search icon
|
|
61
|
+
(isPassword || (isSearch && value)) && "pr-9", // Add padding for toggle/clear button
|
|
62
|
+
"[&::-webkit-search-cancel-button]:appearance-none", // Hide native search cancel button
|
|
63
|
+
className
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
// For regular inputs, render normally
|
|
67
|
+
if (!isPassword && !isSearch) {
|
|
68
|
+
return (
|
|
69
|
+
<input
|
|
70
|
+
type={type}
|
|
71
|
+
className={baseStyles}
|
|
72
|
+
ref={ref}
|
|
73
|
+
aria-invalid={error ? "true" : undefined}
|
|
74
|
+
{...props}
|
|
75
|
+
/>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
27
79
|
return (
|
|
28
|
-
<
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
80
|
+
<div className="relative">
|
|
81
|
+
{isSearch && (
|
|
82
|
+
<FiSearch className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground pointer-events-none" />
|
|
83
|
+
)}
|
|
84
|
+
<input
|
|
85
|
+
type={isPassword ? (isVisible ? "text" : "password") : type}
|
|
86
|
+
className={baseStyles}
|
|
87
|
+
ref={ref}
|
|
88
|
+
aria-invalid={error ? "true" : undefined}
|
|
89
|
+
{...props}
|
|
90
|
+
onChange={handleChange}
|
|
91
|
+
value={props.value !== undefined ? props.value : value}
|
|
92
|
+
/>
|
|
93
|
+
{isPassword && (
|
|
94
|
+
<button
|
|
95
|
+
type="button"
|
|
96
|
+
onClick={() => setIsVisible(!isVisible)}
|
|
97
|
+
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground focus:outline-none"
|
|
98
|
+
>
|
|
99
|
+
{isVisible ? (
|
|
100
|
+
<FiEyeOff className="h-4 w-4" aria-hidden="true" />
|
|
101
|
+
) : (
|
|
102
|
+
<FiEye className="h-4 w-4" aria-hidden="true" />
|
|
103
|
+
)}
|
|
104
|
+
<span className="sr-only">
|
|
105
|
+
{isVisible ? "Hide password" : "Show password"}
|
|
106
|
+
</span>
|
|
107
|
+
</button>
|
|
108
|
+
)}
|
|
109
|
+
{isSearch && value && (
|
|
110
|
+
<button
|
|
111
|
+
type="button"
|
|
112
|
+
onClick={handleClear}
|
|
113
|
+
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground focus:outline-none"
|
|
114
|
+
>
|
|
115
|
+
<FiX className="h-4 w-4" aria-hidden="true" />
|
|
116
|
+
<span className="sr-only">Clear search</span>
|
|
117
|
+
</button>
|
|
34
118
|
)}
|
|
35
|
-
|
|
36
|
-
aria-invalid={error ? "true" : undefined}
|
|
37
|
-
{...props}
|
|
38
|
-
/>
|
|
119
|
+
</div>
|
|
39
120
|
)
|
|
40
121
|
}
|
|
41
122
|
)
|