add form ui

add interface sound hook
This commit is contained in:
2026-05-01 00:22:47 +02:00
parent 608ead88e1
commit 134d70006c
17 changed files with 616 additions and 45 deletions
Binary file not shown.
+30 -3
View File
@@ -1,7 +1,8 @@
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { motion } from "motion/react";
import { type MotionProps, motion } from "motion/react";
import type * as React from "react";
import type { ComponentProps, FC } from "react";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
@@ -59,6 +60,32 @@ function Button({
);
}
const AnimatedButton = motion.create(Button);
const MButton = motion.create(Button);
export { Button, buttonVariants, AnimatedButton };
const AnimatedButton: FC<ComponentProps<typeof Button> & MotionProps> = ({
...props
}) => {
return (
<MButton
variants={{
enter: {
scale: 1,
opacity: 1,
},
from: { scale: 0, opacity: 0 },
exit: { scale: 0.8, opacity: 0 },
}}
whileHover={{
scale: 1.05,
opacity: 1,
}}
whileTap={{
scale: 0.95,
opacity: 1,
}}
{...props}
/>
);
};
export { AnimatedButton, Button, buttonVariants };
+38
View File
@@ -0,0 +1,38 @@
"use client";
import { CheckIcon } from "lucide-react";
import { Checkbox as CheckboxPrimitive } from "radix-ui";
import type * as React from "react";
import useInterfaceSound from "@/hooks/useInterfaceSound";
import { cn } from "@/lib/utils";
function Checkbox({
className,
onCheckedChange,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
const { play } = useInterfaceSound();
return (
<CheckboxPrimitive.Root
onCheckedChange={(checked) => {
if (onCheckedChange) onCheckedChange(checked);
play({ id: "click2" });
}}
data-slot="checkbox"
className={cn(
"peer relative flex size-4 shrink-0 items-center justify-center rounded-sm border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
>
<CheckIcon />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox };
+27
View File
@@ -0,0 +1,27 @@
import { ease } from "@unom/style";
import { motion } from "motion/react";
import type { ReactNode } from "react";
const animation = {
container: {
variants: {
enter: {
opacity: 1,
y: 0,
},
from: {
opacity: 0,
y: -20,
},
},
transition: ease.quint(0.9).out,
},
};
export const FieldContainer = ({ children }: { children: ReactNode }) => {
return (
<motion.div {...animation.container} className="py-3 flex flex-col gap-2">
{children}
</motion.div>
);
};
+31
View File
@@ -0,0 +1,31 @@
import { cva } from "class-variance-authority";
import { motion } from "motion/react";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils.ts";
import { defaultAnimationStagger } from "@/styles/animations";
export const formVariants = cva(cn("py-2 gap-2 max-w-max-form flex flex-col"));
export const Form = ({
children,
id,
onSubmit,
}: {
children: ReactNode;
id?: string;
onSubmit?: () => void;
}) => {
return (
<motion.form
id={id}
onSubmit={(e) => {
e.preventDefault();
onSubmit?.();
}}
{...defaultAnimationStagger}
className={formVariants()}
>
{children}
</motion.form>
);
};
+77
View File
@@ -0,0 +1,77 @@
import * as React from "react";
import useInterfaceSound from "@/hooks/useInterfaceSound";
import { InputText } from "./input-text";
type Props = Omit<
React.ComponentProps<"input">,
"value" | "onChange" | "type"
> & {
value: number;
onChange: (value: number) => void;
min?: number;
max?: number;
step?: number | string;
};
const InputNumber = React.forwardRef<HTMLInputElement, Props>(
({ value, onChange, min, max, onBlur, onFocus, ...rest }, ref) => {
const [draft, setDraft] = React.useState<string>(String(value));
const [focused, setFocused] = React.useState(false);
React.useEffect(() => {
if (!focused && String(value) !== draft) {
setDraft(String(value));
}
}, [value, focused, draft]);
const commit = (raw: string) => {
if (raw === "" || raw === "-") return;
const parsed = Number(raw);
if (!Number.isFinite(parsed)) return;
if (min !== undefined && parsed < min) return;
if (max !== undefined && parsed > max) return;
if (parsed !== value) onChange(parsed);
};
const { play } = useInterfaceSound();
return (
<InputText
ref={ref}
type="number"
inputMode="numeric"
min={min}
max={max}
value={draft}
onFocus={(e) => {
setFocused(true);
play({ id: "click2" });
onFocus?.(e);
}}
onChange={(e) => {
const next = e.target.value;
setDraft(next);
commit(next);
}}
onBlur={(e) => {
setFocused(false);
const parsed = Number(draft);
if (draft === "" || !Number.isFinite(parsed)) {
setDraft(String(value));
} else {
let clamped = parsed;
if (min !== undefined && clamped < min) clamped = min;
if (max !== undefined && clamped > max) clamped = max;
setDraft(String(clamped));
if (clamped !== value) onChange(clamped);
}
onBlur?.(e);
}}
{...rest}
/>
);
},
);
InputNumber.displayName = "InputNumber";
export { InputNumber };
+28
View File
@@ -0,0 +1,28 @@
import * as React from "react";
import useInterfaceSound from "@/hooks/useInterfaceSound";
import { cn } from "@/lib/utils";
const InputText = React.forwardRef<
HTMLInputElement,
React.ComponentProps<"input">
>(({ className, type, ...props }, ref) => {
const { play } = useInterfaceSound();
return (
<input
onFocus={() => {
play({ id: "click2" });
}}
type={type}
className={cn(
"flex h-input-height w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
ref={ref}
{...props}
/>
);
});
InputText.displayName = "InputText";
export { InputText };
+26
View File
@@ -0,0 +1,26 @@
"use client";
import { cva, type VariantProps } from "class-variance-authority";
import { Label as LabelPrimitive } from "radix-ui";
import * as React from "react";
import { cn } from "@/lib/utils";
const labelVariants = cva(
"text-sm font-medium text-main leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };
+221
View File
@@ -0,0 +1,221 @@
"use client";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { Select as SelectPrimitive } from "radix-ui";
import type * as React from "react";
import useInterfaceSound from "@/hooks/useInterfaceSound";
import { cn } from "@/lib/utils";
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
const { play } = useInterfaceSound();
return (
<SelectPrimitive.Root
onOpenChange={(open) => {
if (open) {
play({ id: "click3" });
} else {
play({ id: "click4" });
}
}}
data-slot="select"
{...props}
/>
);
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-main data-placeholder:text-secondary [&_svg:not([class*='text-'])]:text-secondary focus-visible:border-ring",
"focus-visible:ring-main/50 aria-invalid:ring-error/20 dark:aria-invalid:ring-error/40 aria-invalid:border-error",
"dark:bg-neutral-accent/30 dark:hover:bg-neutral-accent/50 flex w-fit items-center justify-between gap-2 rounded-lg",
"border bg-transparent px-4 py-2 text-sm text-main whitespace-nowrap shadow-xs transition-[color,box-shadow,background-color] outline-none",
"focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-input-height data-[size=sm]:h-8",
"*:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center",
"*:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-neutral data-[state=open]:animate-in data-[state=closed]:animate-out p-1",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2",
"data-[side=top]:slide-in-from-bottom-2 relative z-100 max-h-(--radix-select-content-available-height) min-w-32",
"origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width) scroll-my-1",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-main px-3 py-1.5 text-xs", className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
const { play } = useInterfaceSound();
return (
<SelectPrimitive.Item
onMouseEnter={() => play({ id: "click2" })}
data-slot="select-item"
className={cn(
"focus:bg-main/25 text-main/75 focus:text-main [&_svg:not([class*='text-'])]:text-main transition-colors",
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-3",
"text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex",
"*:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-main pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};
+2
View File
@@ -0,0 +1,2 @@
declare module "*.mp3";
declare module "*.wav";
+43
View File
@@ -0,0 +1,43 @@
import { useSound } from "use-sound";
import z from "zod";
import soundSpriteButtons from "@/assets/sounds/762132__ienba__ui-buttons.wav";
import soundSprite from "@/assets/sounds/842498__newlocknew__uimvmt_game-user-interface-sound-set.mp3";
const UiSoundSetSprites = z.enum([
"smooth1",
"smooth2",
"lobbyCreated",
"gameStart",
]);
const UiButtonSprites = z.enum(["click1", "click2", "click3", "click4"]);
export default function useInterfaceSound() {
const [playSprite1] = useSound(soundSprite, {
sprite: {
smooth1: [0, 1200],
smooth2: [7800, 1400],
lobbyCreated: [53800, 3500],
gameStart: [62000, 4000],
},
});
const [playSprite2] = useSound(soundSpriteButtons, {
sprite: {
click1: [0, 1000],
click2: [2750, 1000],
click3: [5200, 1000],
click4: [7700, 1000],
},
});
const play = ({ id }: { id: string }) => {
if (UiSoundSetSprites.safeParse(id).success) {
playSprite1({ id });
} else if (UiButtonSprites.safeParse(id).success) {
playSprite2({ id });
}
};
return { play };
}
+23
View File
@@ -0,0 +1,23 @@
import type { Transition, Variants } from "motion";
export const defaultTransitionCard: Transition = {
type: "spring",
stiffness: 200,
damping: 10,
mass: 1,
};
export const defaultAnimationStagger = {
variants: {
enter: {},
from: {},
},
transition: {
staggerChildren: 0.1,
},
};
export const defaultVariantsCard: Variants = {
enter: { y: 0, opacity: 1 },
from: { opacity: 0, y: 30 },
};