@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@srcroot/ui",
3
- "version": "0.0.42",
3
+ "version": "0.0.43",
4
4
  "description": "A UI library with polymorphic, accessible React components",
5
5
  "type": "module",
6
6
  "bin": {
@@ -63,4 +63,4 @@
63
63
  "optional": true
64
64
  }
65
65
  }
66
- }
66
+ }
@@ -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
- onCheckedChange?.(!checked)
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
- * // With error state
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
- <input
29
- type={type}
30
- className={cn(
31
- "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",
32
- error && "border-destructive focus-visible:ring-destructive",
33
- className
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
- ref={ref}
36
- aria-invalid={error ? "true" : undefined}
37
- {...props}
38
- />
119
+ </div>
39
120
  )
40
121
  }
41
122
  )