Skip to content

Commit 266b963

Browse files
samejrmatt-aitken
authored andcommitted
WIP adding a new stepper input component
1 parent f4ce428 commit 266b963

File tree

2 files changed

+166
-0
lines changed

2 files changed

+166
-0
lines changed
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import { MinusIcon, PlusIcon } from "@heroicons/react/20/solid";
2+
import { useState, useRef, type ChangeEvent } from "react";
3+
import { cn } from "~/utils/cn";
4+
5+
export default function Story() {
6+
const [value1, setValue1] = useState(0);
7+
const [value2, setValue2] = useState(100);
8+
const [value3, setValue3] = useState(0);
9+
10+
return (
11+
<div className="grid h-full max-w-4xl place-items-center">
12+
<div className="flex flex-col gap-4">
13+
<h2 className="text-lg font-semibold text-text-bright">InputStepper examples</h2>
14+
15+
<div className="flex flex-col gap-2">
16+
<label className="text-sm text-text-dimmed">Size: base (default), Step: 75</label>
17+
<InputStepper
18+
value={value1}
19+
onChange={(e) => setValue1(Number(e.target.value))}
20+
step={75}
21+
/>
22+
</div>
23+
24+
<div className="flex flex-col gap-2">
25+
<label className="text-sm text-text-dimmed">
26+
Size: base (default), Step: 50, Max: 1000
27+
</label>
28+
<InputStepper
29+
value={value2}
30+
onChange={(e) => setValue2(Number(e.target.value))}
31+
step={50}
32+
max={1000}
33+
/>
34+
</div>
35+
36+
<div className="flex flex-col gap-2">
37+
<label className="text-sm text-text-dimmed">Disabled state</label>
38+
<InputStepper
39+
value={value3}
40+
onChange={(e) => setValue3(Number(e.target.value))}
41+
step={50}
42+
disabled
43+
/>
44+
</div>
45+
</div>
46+
</div>
47+
);
48+
}
49+
50+
interface InputStepperProps {
51+
value: number;
52+
onChange: (e: ChangeEvent<HTMLInputElement>) => void;
53+
step?: number;
54+
min?: number;
55+
max?: number;
56+
name?: string;
57+
id?: string;
58+
disabled?: boolean;
59+
readOnly?: boolean;
60+
className?: string;
61+
}
62+
63+
function InputStepper({
64+
value,
65+
onChange,
66+
step = 50,
67+
min,
68+
max,
69+
name,
70+
id,
71+
disabled = false,
72+
readOnly = false,
73+
className,
74+
}: InputStepperProps) {
75+
const inputRef = useRef<HTMLInputElement>(null);
76+
77+
const handleStepUp = () => {
78+
if (!inputRef.current || disabled) return;
79+
80+
inputRef.current.stepUp();
81+
82+
// Dispatch a native change event so the onChange handler is called
83+
const event = new Event("change", { bubbles: true });
84+
inputRef.current.dispatchEvent(event);
85+
};
86+
87+
const handleStepDown = () => {
88+
if (!inputRef.current || disabled) return;
89+
90+
inputRef.current.stepDown();
91+
92+
// Dispatch a native change event so the onChange handler is called
93+
const event = new Event("change", { bubbles: true });
94+
inputRef.current.dispatchEvent(event);
95+
};
96+
97+
const isMinDisabled = min !== undefined && value <= min;
98+
const isMaxDisabled = max !== undefined && value >= max;
99+
100+
return (
101+
<div
102+
className={cn(
103+
"flex h-9 items-center rounded border border-charcoal-600 bg-tertiary transition hover:border-charcoal-550/80 hover:bg-charcoal-600/80",
104+
"has-[:focus-visible]:outline has-[:focus-visible]:outline-1 has-[:focus-visible]:outline-offset-0 has-[:focus-visible]:outline-text-link",
105+
"has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50",
106+
className
107+
)}
108+
>
109+
<input
110+
ref={inputRef}
111+
type="number"
112+
id={id}
113+
name={name}
114+
value={value}
115+
onChange={onChange}
116+
step={step}
117+
min={min}
118+
max={max}
119+
disabled={disabled}
120+
readOnly={readOnly}
121+
className={cn(
122+
"placeholder:text-muted-foreground h-full grow border-0 bg-transparent px-3 text-left text-sm text-text-bright outline-none ring-0 focus:border-0 focus:outline-none focus:ring-0 disabled:cursor-not-allowed",
123+
// Hide number input arrows
124+
"[type=number]:border-0 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
125+
)}
126+
/>
127+
128+
<div className="flex items-center gap-1 pr-1.5">
129+
{/* Minus Button */}
130+
<button
131+
type="button"
132+
onClick={handleStepDown}
133+
disabled={disabled || isMinDisabled}
134+
aria-label={`Decrease by ${step}`}
135+
className={cn(
136+
"flex size-6 items-center justify-center rounded border border-error/30 bg-error/20 transition",
137+
"hover:border-error/50 hover:bg-error/30",
138+
"disabled:cursor-not-allowed disabled:opacity-40",
139+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-text-link"
140+
)}
141+
>
142+
<MinusIcon className="h-3.5 w-3.5 text-error" />
143+
</button>
144+
145+
<button
146+
type="button"
147+
onClick={handleStepUp}
148+
disabled={disabled || isMaxDisabled}
149+
aria-label={`Increase by ${step}`}
150+
className={cn(
151+
"flex size-6 items-center justify-center rounded border border-success/30 bg-success/10 transition",
152+
"hover:border-success/40 hover:bg-success/20",
153+
"disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent",
154+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-text-link"
155+
)}
156+
>
157+
<PlusIcon className="h-3.5 w-3.5 text-success" />
158+
</button>
159+
</div>
160+
</div>
161+
);
162+
}

apps/webapp/app/routes/storybook/route.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,10 @@ const stories: Story[] = [
157157
name: "Textarea",
158158
slug: "textarea",
159159
},
160+
{
161+
name: "Stepper",
162+
slug: "stepper",
163+
},
160164
{
161165
sectionTitle: "Menus",
162166
name: "Select",

0 commit comments

Comments
 (0)