Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions frontend/src/components/FormInputRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,19 @@ const FormInputRow: FC<FormInputRowProps> = ({
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" },
Expand Down Expand Up @@ -351,8 +364,9 @@ const FormInputRow: FC<FormInputRowProps> = ({
<RangeSlider
label={input.type == 4 ? "Length Range" : "Items Range"}
min={0}
max={input.type == 4 ? 4000 : 25}
value={[input.min_length || 0, input.max_length || (input.type == 4 ? 255 : 10)]}
max={lengthCeiling}
maxFloor={1}
value={[minLength, maxLength]}
onChange={([min, max]) => {
const updated = { ...input, min_length: min, max_length: max };
setInput(updated);
Expand All @@ -361,6 +375,12 @@ const FormInputRow: FC<FormInputRowProps> = ({
minLabel="Min"
maxLabel="Max"
/>
{input.type === 3 && isApiSelect && (
<p className="text-xs text-gray-400 mt-1">
Discord caps the selection at however many options your API returns, so a range above
that count is trimmed when the form opens.
</p>
)}
</div>
)}

Expand Down
27 changes: 19 additions & 8 deletions frontend/src/components/NumberInput.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -29,6 +29,8 @@ const NumberInput: FC<NumberInputProps> = (props) => {
...props,
};

const [draft, setDraft] = useState<string | null>(null);

const handleDecrement = () => {
if (disabled) return;
if (value - 1 < min) return;
Expand All @@ -42,17 +44,26 @@ const NumberInput: FC<NumberInputProps> = (props) => {
};

const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
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 (
<div className={`flex flex-col ${className}`}>
Expand Down Expand Up @@ -81,9 +92,9 @@ const NumberInput: FC<NumberInputProps> = (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}
Expand Down
79 changes: 60 additions & 19 deletions frontend/src/components/RangeSlider.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<RangeSliderProps> = ({
label,
min,
Expand All @@ -19,50 +25,85 @@ const RangeSlider: FC<RangeSliderProps> = ({
onChange,
minLabel = "Min",
maxLabel = "Max",
maxFloor,
}) => {
const labelId = useId();
const [heldLower, setHeldLower] = useState<number | null>(null);
const [heldUpper, setHeldUpper] = useState<number | null>(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 (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-white">{label}</span>
<div className="flex items-center gap-3 text-sm text-gray-400">
<span>
{minLabel}: <span className="text-white">{value[0]}</span>
</span>
<span>
{maxLabel}: <span className="text-white">{value[1]}</span>
</span>
</div>
</div>
<div role="group" aria-labelledby={labelId} className="flex flex-col gap-2">
<span id={labelId} className="text-white">
{label}
</span>

<RadixSlider.Root
className="relative flex items-center select-none touch-none w-full h-5"
className="relative flex items-center select-none touch-none w-full h-8"
min={min}
max={max}
step={1}
minStepsBetweenThumbs={1}
value={value}
onValueChange={(val) => onChange(val as [number, number])}
onValueChange={([lower, upper]) => {
setHeldLower(null);
setHeldUpper(null);
emit(lower, upper);
}}
>
<RadixSlider.Track className="relative grow rounded-full h-1.5 bg-gray-600">
<RadixSlider.Range className="absolute rounded-full h-full bg-blue-600" />
</RadixSlider.Track>

<RadixSlider.Thumb
className="block w-4 h-4 rounded-full bg-blue-600 shadow-md border-2 border-white cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-gray-700 transition-transform hover:scale-110"
className={`${THUMB_CLASS} before:-left-3 before:right-0`}
aria-label={minLabel}
aria-valuetext={`${minLabel}: ${value[0]}`}
/>
<RadixSlider.Thumb
className="block w-4 h-4 rounded-full bg-blue-600 shadow-md border-2 border-white cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-gray-700 transition-transform hover:scale-110"
className={`${THUMB_CLASS} before:left-0 before:-right-3`}
aria-label={maxLabel}
aria-valuetext={`${maxLabel}: ${value[1]}`}
/>
</RadixSlider.Root>

<div className="flex justify-between text-xs text-gray-500">
<span>{min}</span>
<span>{max}</span>
</div>

<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<NumberInput
label={minLabel}
value={value[0]}
min={min}
max={max}
onChange={handleLowerInput}
/>
<NumberInput
label={maxLabel}
value={value[1]}
min={upperFloor}
max={max}
onChange={handleUpperInput}
/>
</div>
</div>
);
};
Expand Down
Loading