add form ui
add interface sound hook
This commit is contained in:
Binary file not shown.
Binary file not shown.
+30
-3
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
declare module "*.mp3";
|
||||
declare module "*.wav";
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 },
|
||||
};
|
||||
Reference in New Issue
Block a user