Files
bedolaga-cabinet/src/components/primitives/Dialog/Dialog.tsx
kewldan cf3837163e chore(tooling): миграция с ESLint + Prettier на Biome
Один инструмент вместо девяти dev-зависимостей. Замеры на этом репо
(483 файла, медиана из 3 прогонов, один и тот же компьютер):

- eslint .            ~20.4s  ->  biome lint .    ~2s
- prettier --check .  ~8.8s   ->  biome format .  ~0.2s
- суммарно линт+формат ~29s   ->  biome check .   ~2-3s (~10x)

Паритет с прежними правилами:
- форматирование бинарно совместимо с Prettier-конфигом (biome format
  --write тронул 9 файлов из ~480 — микроразличия);
- запрет window.confirm/alert/open/prompt и navigator.clipboard
  (Telegram WebView) перенесён GritQL-плагином
  biome-plugins/telegram-webview-guards.grit c теми же сообщениями
  и исключениями для src/platform/** и clipboard.ts; плагин сразу
  нашёл window.open в Wheel.tsx под старым eslint-disable —
  переведён на biome-ignore;
- react-hooks-правила: useExhaustiveDependencies/useHookAtTopLevel;
- новые для проекта правила (a11y и пр.) не включались или понижены
  до warning, чтобы миграция не смешивалась с чисткой кода — включать
  можно отдельными PR.

Ограничения (задокументированы): сортировка tailwind-классов
(prettier-plugin-tailwindcss) в Biome пока nursery — не включена;
CSS исключён из Biome (парсер спотыкается о Tailwind-синтаксис
globals.css) — файл один и правится редко.

CI не меняется: имена npm-скриптов lint/format:check сохранены.
lint-staged переведён на biome check --write.
2026-07-12 00:12:15 +03:00

175 lines
5.4 KiB
TypeScript

import * as DialogPrimitive from '@radix-ui/react-dialog';
import { motion, AnimatePresence } from 'framer-motion';
import {
forwardRef,
type ComponentPropsWithoutRef,
createContext,
useContext,
useState,
} from 'react';
import { cn } from '@/lib/utils';
import { CloseIcon } from '@/components/icons';
import { backdrop, backdropTransition, scale, scaleTransition } from '../../motion/transitions';
export {
Trigger as DialogTrigger,
Portal as DialogPortal,
Close as DialogClose,
} from '@radix-ui/react-dialog';
// Context for AnimatePresence
const DialogContext = createContext<{ open: boolean }>({ open: false });
// Root
export type DialogProps = ComponentPropsWithoutRef<typeof DialogPrimitive.Root>;
export const Dialog = ({ children, open, onOpenChange, ...props }: DialogProps) => {
const [internalOpen, setInternalOpen] = useState(false);
const isOpen = open !== undefined ? open : internalOpen;
const handleOpenChange = onOpenChange || setInternalOpen;
return (
<DialogPrimitive.Root open={isOpen} onOpenChange={handleOpenChange} {...props}>
<DialogContext.Provider value={{ open: isOpen }}>{children}</DialogContext.Provider>
</DialogPrimitive.Root>
);
};
// Overlay
export type DialogOverlayProps = ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>;
export const DialogOverlay = forwardRef<HTMLDivElement, DialogOverlayProps>(
({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn('fixed inset-0 z-50 bg-dark-950/60 backdrop-blur-sm', className)}
asChild
{...props}
>
<motion.div
variants={backdrop}
initial="initial"
animate="animate"
exit="exit"
transition={backdropTransition}
/>
</DialogPrimitive.Overlay>
),
);
DialogOverlay.displayName = 'DialogOverlay';
// Content
export interface DialogContentProps
extends ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {
showCloseButton?: boolean;
}
export const DialogContent = forwardRef<HTMLDivElement, DialogContentProps>(
({ className, children, showCloseButton = true, ...props }, ref) => {
const { open } = useContext(DialogContext);
return (
<DialogPrimitive.Portal forceMount>
<AnimatePresence mode="wait">
{open && (
<>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2',
'max-h-[85vh] w-full max-w-lg',
'grid gap-4 overflow-auto',
'rounded-linear-lg border border-dark-700/50 bg-dark-900/95 backdrop-blur-linear',
'p-6 shadow-linear-lg',
'focus:outline-none',
className,
)}
asChild
{...props}
>
<motion.div
variants={scale}
initial="initial"
animate="animate"
exit="exit"
transition={scaleTransition}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
className={cn(
'absolute right-4 top-4 rounded-linear p-1.5',
'text-dark-400 opacity-70 transition-all',
'hover:bg-dark-800/80 hover:opacity-100',
'focus:outline-none focus:ring-2 focus:ring-accent-500/50',
)}
>
<CloseIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</motion.div>
</DialogPrimitive.Content>
</>
)}
</AnimatePresence>
</DialogPrimitive.Portal>
);
},
);
DialogContent.displayName = 'DialogContent';
// Header
export type DialogHeaderProps = React.HTMLAttributes<HTMLDivElement>;
export const DialogHeader = ({ className, ...props }: DialogHeaderProps) => (
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
);
DialogHeader.displayName = 'DialogHeader';
// Footer
export type DialogFooterProps = React.HTMLAttributes<HTMLDivElement>;
export const DialogFooter = ({ className, ...props }: DialogFooterProps) => (
<div
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
{...props}
/>
);
DialogFooter.displayName = 'DialogFooter';
// Title
export type DialogTitleProps = ComponentPropsWithoutRef<typeof DialogPrimitive.Title>;
export const DialogTitle = forwardRef<HTMLHeadingElement, DialogTitleProps>(
({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold text-dark-100', className)}
{...props}
/>
),
);
DialogTitle.displayName = 'DialogTitle';
// Description
export type DialogDescriptionProps = ComponentPropsWithoutRef<typeof DialogPrimitive.Description>;
export const DialogDescription = forwardRef<HTMLParagraphElement, DialogDescriptionProps>(
({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-dark-400', className)}
{...props}
/>
),
);
DialogDescription.displayName = 'DialogDescription';