feat: add react-scan in dev

This commit is contained in:
Aarnav Tale
2025-01-24 08:17:12 -05:00
parent 665509e710
commit 0f75636342
8 changed files with 577 additions and 133 deletions
+15 -12
View File
@@ -1,12 +1,12 @@
import type { Dispatch, SetStateAction } from 'react';
import React, { useRef } from 'react';
import { useButton, type AriaButtonOptions } from 'react-aria';
import { type AriaButtonOptions, useButton } from 'react-aria';
import { cn } from '~/utils/cn';
export interface ButtonProps extends AriaButtonOptions<'button'> {
variant?: 'heavy' | 'light'
className?: string
children?: React.ReactNode
variant?: 'heavy' | 'light' | 'danger';
className?: string;
children?: React.ReactNode;
}
export default function Button({ variant = 'light', ...props }: ButtonProps) {
@@ -23,17 +23,20 @@ export default function Button({ variant = 'light', ...props }: ButtonProps) {
props.isDisabled && 'opacity-60 cursor-not-allowed',
...(variant === 'heavy'
? [
'bg-headplane-900 dark:bg-headplane-50 font-semibold',
'hover:bg-headplane-900/90 dark:hover:bg-headplane-50/90',
'text-headplane-200 dark:text-headplane-800'
] : [
'bg-headplane-100 dark:bg-headplane-700/30 font-medium',
'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30',
]),
'bg-headplane-900 dark:bg-headplane-50 font-semibold',
'hover:bg-headplane-900/90 dark:hover:bg-headplane-50/90',
'text-headplane-200 dark:text-headplane-800',
]
: variant === 'danger'
? ['bg-red-500 text-white font-semibold', 'hover:bg-red-500/90']
: [
'bg-headplane-100 dark:bg-headplane-700/30 font-medium',
'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30',
]),
props.className,
)}
>
{props.children}
</button>
)
);
}
+133 -74
View File
@@ -1,97 +1,156 @@
import React, { Dispatch, ReactNode, SetStateAction } from 'react';
import Button, { ButtonProps } from '~/components/Button';
import Title from '~/components/Title';
import Text from '~/components/Text';
import Card from '~/components/Card';
import React, { cloneElement, useRef } from 'react';
import {
Dialog as AriaDialog,
DialogTrigger,
Modal,
ModalOverlay,
} from 'react-aria-components';
type AriaDialogProps,
type AriaModalOverlayProps,
Overlay,
useDialog,
useModalOverlay,
useOverlayTrigger,
} from 'react-aria';
import { Form, type HTMLFormMethod } from 'react-router';
import {
type OverlayTriggerProps,
type OverlayTriggerState,
useOverlayTriggerState,
} from 'react-stately';
import Button, { ButtonProps } from '~/components/Button';
import Card from '~/components/Card';
import IconButton, { IconButtonProps } from '~/components/IconButton';
import Text from '~/components/Text';
import Title from '~/components/Title';
import { cn } from '~/utils/cn';
interface ActionProps extends Omit<ButtonProps, 'variant'> {
variant: 'cancel' | 'confirm';
export interface DialogProps extends OverlayTriggerProps {
children: [
React.ReactElement<ButtonProps> | React.ReactElement<IconButtonProps>,
React.ReactElement<DialogPanelProps>,
];
}
function Action(props: ActionProps) {
return (
<Button
{...props}
type={props.variant === 'confirm' ? 'submit' : 'button'}
variant={props.variant === 'cancel' ? 'light' : 'heavy'}
/>
function Dialog(props: DialogProps) {
const state = useOverlayTriggerState(props);
const { triggerProps, overlayProps } = useOverlayTrigger(
{
type: 'dialog',
},
state,
);
}
interface GutterProps {
children: ReactNode;
}
function Gutter({ children }: GutterProps) {
const [button, panel] = props.children;
return (
<div className="mt-6 flex justify-end gap-4 mt-6">
{children}
</div>
)
}
interface PanelProps {
children: (close: () => void) => ReactNode;
control?: [boolean, Dispatch<SetStateAction<boolean>>];
className?: string;
}
function Panel({ children, control, className }: PanelProps) {
return (
<ModalOverlay
aria-hidden="true"
className={cn(
'fixed inset-0 h-screen w-screen z-50',
'flex items-center justify-center',
'bg-headplane-900/15 dark:bg-headplane-900/30',
'entering:animate-in exiting:animate-out',
'entering:fade-in entering:duration-100 entering:ease-out',
'exiting:fade-out exiting:duration-50 exiting:ease-in',
className,
<>
{cloneElement(button, triggerProps)}
{state.isOpen && (
<DModal state={state}>
{cloneElement(panel, {
...overlayProps,
close: () => state.close(),
})}
</DModal>
)}
isOpen={control ? control[0] : undefined}
onOpenChange={control ? control[1] : undefined}
>
<Modal className={cn(
'bg-white dark:bg-headplane-900 rounded-3xl w-full max-w-lg',
'entering:animate-in exiting:animate-out',
'entering:zoom-in-95 entering:ease-out entering:duration-100',
'exiting:zoom-out-95 exiting:ease-in exiting:duration-50',
)}>
<Card variant="flat" className="w-full max-w-lg">
<AriaDialog role="alertdialog" className="outline-none">
{({ close }) => children(close)}
</AriaDialog>
</Card>
</Modal>
</ModalOverlay>
</>
);
}
interface DialogProps {
children: ReactNode;
control?: [boolean, Dispatch<SetStateAction<boolean>>];
export interface DialogPanelProps extends AriaDialogProps {
children: React.ReactNode;
variant?: 'normal' | 'destructive';
onSubmit?: React.FormEventHandler<HTMLFormElement>;
method?: HTMLFormMethod;
// Anonymous (passed by parent)
close?: () => void;
}
function Dialog({ children, control }: DialogProps) {
if (control) {
return children;
function Panel(props: DialogPanelProps) {
const { children, onSubmit, close, variant, method = 'POST' } = props;
const ref = useRef<HTMLFormElement | null>(null);
const { dialogProps } = useDialog(
{
...props,
role: 'alertdialog',
},
ref,
);
return (
<Form
{...dialogProps}
onSubmit={(event) => {
if (onSubmit) {
onSubmit(event);
}
close?.();
}}
method={method ?? 'POST'}
ref={ref}
className={cn(
'outline-none rounded-3xl w-full max-w-lg',
'bg-white dark:bg-headplane-900',
)}
>
<Card className="w-full max-w-lg">
{children}
<div className="mt-6 flex justify-end gap-4">
<Button onPress={close}>Cancel</Button>
<Button
type="submit"
variant={variant === 'destructive' ? 'danger' : 'heavy'}
isDisabled={!(ref.current?.checkVisibility() ?? false)}
>
Confirm
</Button>
</div>
</Card>
</Form>
);
}
interface DModalProps extends AriaModalOverlayProps {
children: React.ReactNode;
state: OverlayTriggerState;
}
function DModal(props: DModalProps) {
const { children, state } = props;
const ref = useRef<HTMLDivElement>(null);
const { modalProps, underlayProps } = useModalOverlay(props, state, ref);
if (!state.isOpen) {
return null;
}
return <DialogTrigger>{children}</DialogTrigger>;
return (
<Overlay>
<div
{...underlayProps}
aria-hidden="true"
className={cn(
'fixed inset-0 h-screen w-screen z-50',
'flex items-center justify-center',
'bg-headplane-900/15 dark:bg-headplane-900/30',
'entering:animate-in exiting:animate-out',
'entering:fade-in entering:duration-100 entering:ease-out',
'exiting:fade-out exiting:duration-50 exiting:ease-in',
)}
/>
<div
{...modalProps}
className={cn(
'fixed inset-0 h-screen w-screen z-50',
'flex items-center justify-center',
)}
>
{children}
</div>
</Overlay>
);
}
export default Object.assign(Dialog, {
Action,
Button,
Gutter,
IconButton,
Panel,
Title,
Text,
+65
View File
@@ -0,0 +1,65 @@
import { useRef } from 'react';
import { type AriaTextFieldProps, useTextField } from 'react-aria';
import cn from '~/utils/cn';
export interface InputProps extends AriaTextFieldProps<HTMLInputElement> {
isRequired?: boolean;
}
export default function Input(props: InputProps) {
const { label } = props;
const ref = useRef<HTMLInputElement | null>(null);
const {
labelProps,
inputProps,
descriptionProps,
errorMessageProps,
isInvalid,
validationErrors,
} = useTextField(props, ref);
return (
<div className="flex flex-col">
<label
{...labelProps}
htmlFor={props.name}
className={cn(
'text-xs font-medium px-3 mb-0.5',
'text-headplane-700 dark:text-headplane-100',
)}
>
{label}
</label>
<input
{...inputProps}
required={props.isRequired}
ref={ref}
className={cn(
'rounded-xl px-3 py-2',
'focus:outline-none focus:ring',
'bg-white dark:bg-headplane-900',
'border border-headplane-100 dark:border-headplane-800',
)}
/>
{props.description && (
<div
{...descriptionProps}
className={cn(
'text-xs px-3 mt-1',
'text-headplane-500 dark:text-headplane-400',
)}
>
{props.description}
</div>
)}
{isInvalid && (
<div
{...errorMessageProps}
className={cn('text-xs px-3 mt-1', 'text-red-500 dark:text-red-400')}
>
{validationErrors.join(' ')}
</div>
)}
</div>
);
}
+99
View File
@@ -0,0 +1,99 @@
import { Minus, Plus } from 'lucide-react';
import { useRef } from 'react';
import {
type AriaNumberFieldProps,
useLocale,
useNumberField,
} from 'react-aria';
import { useNumberFieldState } from 'react-stately';
import IconButton from '~/components/IconButton';
import cn from '~/utils/cn';
export interface InputProps extends AriaNumberFieldProps {
isRequired?: boolean;
name?: string;
}
export default function NumberInput(props: InputProps) {
const { label, name } = props;
const { locale } = useLocale();
const state = useNumberFieldState({ ...props, locale });
const ref = useRef<HTMLInputElement | null>(null);
const {
labelProps,
inputProps,
groupProps,
incrementButtonProps,
decrementButtonProps,
descriptionProps,
errorMessageProps,
isInvalid,
validationErrors,
} = useNumberField(props, state, ref);
return (
<div className="flex flex-col">
<label
{...labelProps}
htmlFor={name}
className={cn(
'text-xs font-medium px-3 mb-0.5',
'text-headplane-700 dark:text-headplane-100',
)}
>
{label}
</label>
<div
{...groupProps}
className={cn(
'flex items-center gap-1 rounded-xl pr-1',
'focus-within:outline-none focus-within:ring',
'bg-white dark:bg-headplane-900',
'border border-headplane-100 dark:border-headplane-800',
)}
>
<input
{...inputProps}
required={props.isRequired}
name={name}
ref={ref}
className="w-full pl-3 py-2 rounded-l-xl focus:outline-none"
/>
<IconButton
{...decrementButtonProps}
label="Decrement"
className="w-7.5 h-7.5 rounded-lg"
>
<Minus className="p-1" />
</IconButton>
<IconButton
{...incrementButtonProps}
label="Increment"
className="w-7.5 h-7.5 rounded-lg"
>
<Plus className="p-1" />
</IconButton>
</div>
{props.description && (
<div
{...descriptionProps}
className={cn(
'text-xs px-3 mt-1',
'text-headplane-500 dark:text-headplane-400',
)}
>
{props.description}
</div>
)}
{isInvalid && (
<div
{...errorMessageProps}
className={cn('text-xs px-3 mt-1', 'text-red-500 dark:text-red-400')}
>
{validationErrors.join(' ')}
</div>
)}
</div>
);
}