| 1 | "use client"; |
| 2 | |
| 3 | import { ChevronDown, ChevronUp } from "lucide-react"; |
| 4 | import type * as React from "react"; |
| 5 | |
| 6 | import { cn } from "@/lib/utils"; |
| 7 | |
| 8 | interface InputGroupNumberProps extends Omit< |
| 9 | React.ComponentProps<"input">, |
| 10 | "type" | "onChange" |
| 11 | > { |
| 12 | value: number; |
| 13 | onChange: (value: number) => void; |
| 14 | min?: number; |
| 15 | max?: number; |
| 16 | step?: number; |
| 17 | } |
| 18 | |
| 19 | /** |
| 20 | * A custom number input component using InputGroup pattern. |
| 21 | * Features up/down arrow buttons on the right side for incrementing/decrementing. |
| 22 | */ |
| 23 | function InputGroupNumber({ |
| 24 | className, |
| 25 | value, |
| 26 | onChange, |
| 27 | min = 1, |
| 28 | max = 50, |
| 29 | step = 1, |
| 30 | disabled, |
| 31 | ...props |
| 32 | }: InputGroupNumberProps) { |
| 33 | const handleIncrement = () => { |
| 34 | const newValue = Math.min(max, value + step); |
| 35 | onChange(newValue); |
| 36 | }; |
| 37 | |
| 38 | const handleDecrement = () => { |
| 39 | const newValue = Math.max(min, value - step); |
| 40 | onChange(newValue); |
| 41 | }; |
| 42 | |
| 43 | const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { |
| 44 | const inputValue = e.target.value; |
| 45 | if (inputValue === "") { |
| 46 | onChange(min); |
| 47 | return; |
| 48 | } |
| 49 | const parsedValue = parseInt(inputValue, 10); |
| 50 | if (!Number.isNaN(parsedValue)) { |
| 51 | const clampedValue = Math.max(min, Math.min(max, parsedValue)); |
| 52 | onChange(clampedValue); |
| 53 | } |
| 54 | }; |
| 55 | |
| 56 | const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { |
| 57 | if (e.key === "ArrowUp") { |
| 58 | e.preventDefault(); |
| 59 | handleIncrement(); |
| 60 | } else if (e.key === "ArrowDown") { |
| 61 | e.preventDefault(); |
| 62 | handleDecrement(); |
| 63 | } |
| 64 | }; |
| 65 | |
| 66 | return ( |
| 67 | <div |
| 68 | data-slot="input-group" |
| 69 | role="group" |
| 70 | data-disabled={disabled} |
| 71 | className={cn( |
| 72 | "group/input-group relative flex w-full items-center rounded-md border border-input shadow-2xs outline-hidden transition-[color,box-shadow] dark:bg-input/30", |
| 73 | "h-9", |
| 74 | // Focus state |
| 75 | "has-[[data-slot=input-group-control]:focus-visible]:ring-1 has-[[data-slot=input-group-control]:focus-visible]:ring-ring", |
| 76 | // Disabled state |
| 77 | disabled && "cursor-not-allowed opacity-50", |
| 78 | className, |
| 79 | )} |
| 80 | > |
| 81 | {/* Number Input */} |
| 82 | <input |
| 83 | type="text" |
| 84 | inputMode="numeric" |
| 85 | pattern="[0-9]*" |
| 86 | data-slot="input-group-control" |
| 87 | value={value} |
| 88 | onChange={handleInputChange} |
| 89 | onKeyDown={handleKeyDown} |
| 90 | disabled={disabled} |
| 91 | className={cn( |
| 92 | "h-full w-10 flex-1 bg-transparent px-3 py-1.5 text-sm outline-hidden", |
| 93 | "[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none", |
| 94 | disabled && "cursor-not-allowed", |
| 95 | )} |
| 96 | {...props} |
| 97 | /> |
| 98 | |
| 99 | {/* Increment/Decrement Buttons */} |
| 100 | <div |
| 101 | data-slot="input-group-addon" |
| 102 | data-align="inline-end" |
| 103 | className="flex h-full flex-col border-l border-input" |
| 104 | > |
| 105 | <button |
| 106 | type="button" |
| 107 | onClick={handleIncrement} |
| 108 | disabled={disabled || value >= max} |
| 109 | tabIndex={-1} |
| 110 | aria-label="Increment" |
| 111 | className={cn( |
| 112 | "flex h-1/2 items-center justify-center px-2 text-muted-foreground transition-colors", |
| 113 | "hover:bg-muted/50 hover:text-foreground", |
| 114 | "disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent disabled:hover:text-muted-foreground", |
| 115 | "border-b border-input", |
| 116 | )} |
| 117 | > |
| 118 | <ChevronUp className="size-3.5" /> |
| 119 | </button> |
| 120 | <button |
| 121 | type="button" |
| 122 | onClick={handleDecrement} |
| 123 | disabled={disabled || value <= min} |
| 124 | tabIndex={-1} |
| 125 | aria-label="Decrement" |
| 126 | className={cn( |
| 127 | "flex h-1/2 items-center justify-center px-2 text-muted-foreground transition-colors", |
| 128 | "hover:bg-muted/50 hover:text-foreground", |
| 129 | "disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent disabled:hover:text-muted-foreground", |
| 130 | )} |
| 131 | > |
| 132 | <ChevronDown className="size-3.5" /> |
| 133 | </button> |
| 134 | </div> |
| 135 | </div> |
| 136 | ); |
| 137 | } |
| 138 | |
| 139 | export { InputGroupNumber }; |
| 140 |