mirror of
https://github.com/tale/headplane.git
synced 2026-08-24 11:36:31 +00:00
feat: add react-scan in dev
This commit is contained in:
+15
-12
@@ -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
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user