返回 presentation-ai
input-group-number.tsx
根目录 / src / components / ui / input-group-number.tsx
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
140 lines Plain Text