diff --git a/frontend/src/components/FormInputRow.tsx b/frontend/src/components/FormInputRow.tsx index 57d5ed66..5ae4f7cc 100644 --- a/frontend/src/components/FormInputRow.tsx +++ b/frontend/src/components/FormInputRow.tsx @@ -190,6 +190,19 @@ const FormInputRow: FC = ({ onValidationChangeRef.current?.(hasValidationErrors); }, [hasValidationErrors]); + const optionCount = input.options?.length ?? 0; + const lengthCeiling = (() => { + if (input.type === 4) return 4000; + if (input.type === 22) return Math.min(10, optionCount || 10); + if (input.type === 3 && !isApiSelect) return Math.min(25, optionCount || 25); + return 25; + })(); + const maxLength = Math.min( + lengthCeiling, + Math.max(1, input.max_length ?? (input.type === 4 ? 255 : 10)), + ); + const minLength = Math.min(Math.max(0, input.min_length ?? 0), maxLength); + const inputTypes = [ { label: "Text Input", key: "4" }, { label: "String Select", key: "3" }, @@ -351,8 +364,9 @@ const FormInputRow: FC = ({ { const updated = { ...input, min_length: min, max_length: max }; setInput(updated); @@ -361,6 +375,12 @@ const FormInputRow: FC = ({ minLabel="Min" maxLabel="Max" /> + {input.type === 3 && isApiSelect && ( +

+ Discord caps the selection at however many options your API returns, so a range above + that count is trimmed when the form opens. +

+ )} )} diff --git a/frontend/src/components/NumberInput.tsx b/frontend/src/components/NumberInput.tsx index 72926b0c..5eb8d756 100644 --- a/frontend/src/components/NumberInput.tsx +++ b/frontend/src/components/NumberInput.tsx @@ -1,4 +1,4 @@ -import { useId, type FC, type ChangeEvent } from "react"; +import { useId, useState, type FC, type ChangeEvent } from "react"; interface NumberInputProps { value: number; @@ -29,6 +29,8 @@ const NumberInput: FC = (props) => { ...props, }; + const [draft, setDraft] = useState(null); + const handleDecrement = () => { if (disabled) return; if (value - 1 < min) return; @@ -42,17 +44,26 @@ const NumberInput: FC = (props) => { }; const handleChange = (e: ChangeEvent) => { - let val = parseInt(e.target.value, 10); - if (isNaN(val)) { - val = min; + const raw = e.target.value; + if (raw === "") { + setDraft(""); + return; } - val = Math.max(min, Math.min(max, val)); - onChange(val); + const val = parseInt(raw, 10); + if (isNaN(val)) return; + setDraft(null); + onChange(Math.max(min, Math.min(max, val))); }; const inputId = useId(); const errorId = useId(); const { onBlur, error } = props; + + const handleBlur = () => { + setDraft(null); + onBlur?.(); + }; + const borderClass = error ? "border-red-500" : "border-neutral-600 focus-within:border-blue-500"; return (
@@ -81,9 +92,9 @@ const NumberInput: FC = (props) => { aria-valuemax={max} aria-valuenow={value} className="text-center w-full bg-transparent text-white focus:outline-none py-2" - value={value} + value={draft ?? value} onChange={handleChange} - onBlur={onBlur} + onBlur={handleBlur} disabled={disabled} placeholder={placeholder || "Enter number"} aria-invalid={error ? true : undefined} diff --git a/frontend/src/components/RangeSlider.tsx b/frontend/src/components/RangeSlider.tsx index 1fb0c02b..6edc042b 100644 --- a/frontend/src/components/RangeSlider.tsx +++ b/frontend/src/components/RangeSlider.tsx @@ -1,5 +1,7 @@ import * as RadixSlider from "@radix-ui/react-slider"; +import { useId, useState } from "react"; import type { FC } from "react"; +import NumberInput from "./NumberInput"; interface RangeSliderProps { label: string; @@ -9,8 +11,12 @@ interface RangeSliderProps { onChange: (value: [number, number]) => void; minLabel?: string; maxLabel?: string; + maxFloor?: number; } +const THUMB_CLASS = + "relative block w-4 h-4 rounded-full bg-blue-600 shadow-md border-2 border-white cursor-pointer transition-colors hover:bg-blue-500 before:content-[''] before:absolute before:-inset-y-2"; + const RangeSlider: FC = ({ label, min, @@ -19,43 +25,61 @@ const RangeSlider: FC = ({ onChange, minLabel = "Min", maxLabel = "Max", + maxFloor, }) => { + const labelId = useId(); + const [heldLower, setHeldLower] = useState(null); + const [heldUpper, setHeldUpper] = useState(null); + const upperFloor = Math.min(max, Math.max(min, maxFloor ?? min)); + + const emit = (lower: number, upper: number) => { + const nextUpper = Math.min(max, Math.max(upperFloor, upper)); + onChange([Math.min(Math.max(min, lower), nextUpper), nextUpper]); + }; + + const handleLowerInput = (next: number) => { + const base = heldUpper ?? value[1]; + setHeldLower(null); + setHeldUpper(next > base ? base : null); + emit(next, Math.max(base, next)); + }; + + const handleUpperInput = (next: number) => { + const base = heldLower ?? value[0]; + setHeldUpper(null); + setHeldLower(next < base ? base : null); + emit(Math.min(base, next), next); + }; + return ( -
-
- {label} -
- - {minLabel}: {value[0]} - - - {maxLabel}: {value[1]} - -
-
+
+ + {label} + onChange(val as [number, number])} + onValueChange={([lower, upper]) => { + setHeldLower(null); + setHeldUpper(null); + emit(lower, upper); + }} > @@ -63,6 +87,23 @@ const RangeSlider: FC = ({ {min} {max}
+ +
+ + +
); };