Files
auth-portal/apps/web/src/design-preview/bits.tsx
T

482 lines
15 KiB
TypeScript

import { useState, type ReactNode } from 'react'
import { CircleAlertIcon, InfoIcon, TriangleAlertIcon, XIcon } from 'lucide-react'
import { toast } from 'sonner'
import { Alert, AlertAction, AlertDescription, AlertTitle } from '@/components/reui/alert'
import { Badge } from '@/components/reui/badge'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@authportal/ui/components/alert-dialog'
import { Button } from '@authportal/ui/components/button'
import { cn } from '@authportal/ui/lib/utils'
import { Input } from '@authportal/ui/components/input'
import { Label } from '@authportal/ui/components/label'
import { Tabs, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@authportal/ui/components/sheet'
import type { Tone } from './mock'
import { TONE_BADGE } from './tone'
const TONE_GLYPH: Record<Tone, string> = { ok: '●', wn: '▲', er: '✕', mu: '○', in: '' }
/** Плашка статуса: слово + глиф (● ▲ ✕ ○), чтобы цвет не был единственным признаком. */
export function StatusBadge({ tone, children }: { tone: Tone; children: ReactNode }) {
const glyph = TONE_GLYPH[tone]
return (
<Badge variant={TONE_BADGE[tone]} size="sm" className="whitespace-nowrap">
{glyph ? <span aria-hidden className="text-[0.55rem]">{glyph}</span> : null}
{children}
</Badge>
)
}
/** Заголовок страницы: название, описание, метка свежести и действия. */
export function PageHead({
title,
sub,
actions,
}: {
title: string
sub: string
actions?: ReactNode
}) {
return (
<header className="flex flex-wrap items-end justify-between gap-3">
<div className="flex min-w-0 flex-col gap-0.5">
<h1 className="text-xl font-semibold tracking-[-0.01em]">{title}</h1>
<p className="text-muted-foreground text-sm">{sub}</p>
</div>
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
</header>
)
}
const ALERT_ICON = {
er: <CircleAlertIcon aria-hidden />,
wn: <TriangleAlertIcon aria-hidden />,
in: <InfoIcon aria-hidden />,
} as const
const ALERT_VARIANT = {
er: 'destructive',
wn: 'warning',
in: 'info',
} as const
export function SignalAlert({
tone,
title,
children,
action,
}: {
tone: 'er' | 'wn' | 'in'
title: string
children: ReactNode
action?: ReactNode
}) {
return (
<Alert variant={ALERT_VARIANT[tone]}>
{ALERT_ICON[tone]}
<AlertTitle>{title}</AlertTitle>
<AlertDescription>{children}</AlertDescription>
{action ? <AlertAction>{action}</AlertAction> : null}
</Alert>
)
}
/** Боковое окно (как CreateUserSheet): заголовок, прокручиваемое тело, подвал. */
export function PreviewSheet({
open,
onOpenChange,
title,
description,
children,
footer,
size = 'md',
dirty,
}: {
open: boolean
onOpenChange: (open: boolean) => void
title: string
description: string
children: ReactNode
footer?: ReactNode
/** Ширина панели: md — форма, lg — редактор с двумя колонками. */
size?: 'md' | 'lg'
/** Явный признак изменений (иначе ловим ввод в полях и переключатели). */
dirty?: boolean
}) {
const [touchedState, setTouched] = useState(false)
const touched = dirty ?? touchedState
const [guard, setGuard] = useState(false)
function requestChange(next: boolean) {
if (!next && touched) {
setGuard(true)
return
}
if (!next) setTouched(false)
onOpenChange(next)
}
return (
<>
<Sheet open={open} onOpenChange={requestChange}>
<SheetContent
side="right"
showCloseButton={false}
className={cn(
'inset-y-0 right-0 left-auto flex h-svh max-w-none flex-col gap-0 overflow-hidden rounded-none p-0 outline-none sm:max-w-none',
'data-[side=right]:sm:max-w-none',
size === 'lg'
? 'data-[side=right]:w-[min(45rem,100vw)]'
: 'data-[side=right]:w-[min(30rem,100vw)]',
)}
>
<SheetHeader className="shrink-0 gap-0 p-0">
<div className="flex min-h-12 items-center justify-between gap-2 border-b px-4">
<SheetTitle className="min-w-0 truncate text-base font-semibold">{title}</SheetTitle>
<SheetClose
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Закрыть"
className="text-muted-foreground hover:text-foreground"
/>
}
>
<XIcon aria-hidden="true" />
</SheetClose>
</div>
<SheetDescription className="text-muted-foreground px-4 pt-3 text-sm">
{description}
</SheetDescription>
</SheetHeader>
<div
className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 py-4"
onInput={() => setTouched(true)}
onClick={(e) => {
if ((e.target as HTMLElement).closest('[role=switch]')) setTouched(true)
}}
>
{children}
</div>
{footer ? (
<SheetFooter
className="flex-row flex-wrap justify-end gap-2 border-t px-4 py-3"
onClickCapture={(e) => {
// «Закрыть» и «Отмена» при изменённой форме тоже спрашивают подтверждение.
const label = (e.target as HTMLElement).closest('button')?.textContent?.trim()
if (touched && (label === 'Закрыть' || label === 'Отмена')) {
e.stopPropagation()
setGuard(true)
}
}}
>{footer}</SheetFooter>
) : null}
</SheetContent>
</Sheet>
<AlertDialog open={guard} onOpenChange={setGuard}>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>Закрыть без сохранения?</AlertDialogTitle>
<AlertDialogDescription>Изменения в форме будут потеряны.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Остаться</AlertDialogCancel>
<Button
type="button"
variant="destructive"
onClick={() => {
setGuard(false)
setTouched(false)
onOpenChange(false)
}}
>
Закрыть без сохранения
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
/** Подтверждение опасного действия вводом слова (email, «сбросить», «очистить»…). */
export function TypedConfirm({
open,
onOpenChange,
title,
description,
expected,
confirmLabel,
onConfirm,
}: {
open: boolean
onOpenChange: (open: boolean) => void
title: string
description: ReactNode
expected: string
confirmLabel: string
onConfirm: () => void
}) {
const [value, setValue] = useState('')
const matches = value.trim().toLowerCase() === expected.toLowerCase()
return (
<AlertDialog
open={open}
onOpenChange={(next) => {
if (!next) setValue('')
onOpenChange(next)
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<div className="flex flex-col gap-2">
<Label htmlFor="typed-confirm">
Введите <span className="font-mono font-semibold">{expected}</span> для подтверждения
</Label>
<Input
id="typed-confirm"
value={value}
autoComplete="off"
onChange={(e) => setValue(e.target.value)}
aria-invalid={value.length > 0 && !matches}
/>
</div>
<AlertDialogFooter>
<AlertDialogCancel>Отмена</AlertDialogCancel>
<Button
type="button"
variant="destructive"
disabled={!matches}
onClick={() => {
setValue('')
onConfirm()
}}
>
{confirmLabel}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
export function KvFrame({
title,
description,
items,
}: {
title: string
description: string
items: Array<{ label: string; value: ReactNode }>
}) {
return (
<Frame className="[--frame-panel-header-py-adjust:4px] w-full">
<FrameHeader>
<FrameTitle>{title}</FrameTitle>
<FrameDescription>{description}</FrameDescription>
</FrameHeader>
<FramePanel className="p-0">
<dl className="grid divide-y @container sm:grid-cols-2 sm:divide-y-0">
{items.map((item) => (
<div
key={item.label}
className="flex flex-col gap-0.5 border-b px-4 py-3 last:border-b-0 sm:odd:border-r"
>
<dt className="text-muted-foreground text-xs">{item.label}</dt>
<dd className="text-sm font-medium break-words">{item.value}</dd>
</div>
))}
</dl>
</FramePanel>
</Frame>
)
}
/** Список «значение + подпись + копировать» (параметры issuer). */
export function CopyList({
title,
description,
items,
}: {
title: string
description: string
items: Array<{ label: string; value: string }>
}) {
return (
<Frame className="[--frame-panel-header-py-adjust:4px] w-full">
<FrameHeader>
<FrameTitle>{title}</FrameTitle>
<FrameDescription>{description}</FrameDescription>
</FrameHeader>
<FramePanel className="p-0">
<ul className="flex flex-col">
{items.map((item) => (
<li
key={item.label}
className="flex flex-wrap items-center gap-x-3 gap-y-1 border-b px-4 py-2.5 last:border-b-0"
>
<code className="min-w-0 flex-1 basis-72 font-mono text-[13px] break-all">{item.value}</code>
<span className="text-muted-foreground min-w-0 flex-1 basis-48 text-[13px]">{item.label}</span>
<Button
type="button"
variant="outline"
aria-label={`Скопировать: ${item.label}`}
onClick={() => {
void navigator.clipboard?.writeText(item.value).catch(() => undefined)
toast.success(`${item.label} скопирован`)
}}
>
Копировать
</Button>
</li>
))}
</ul>
</FramePanel>
</Frame>
)
}
/** Компактный список «ключ — значение» для боковых окон. */
export function KvList({ items }: { items: Array<[string, string]> }) {
return (
<dl className="flex flex-col divide-y rounded-lg border">
{items.map(([k, v]) => (
<div key={k} className="flex flex-col gap-0.5 px-3 py-2">
<dt className="text-muted-foreground text-xs">{k}</dt>
<dd className="text-sm break-words">{v}</dd>
</div>
))}
</dl>
)
}
export function CodeBlock({ label, children }: { label: string; children: string }) {
return (
<div className="flex flex-col gap-1">
<span className="text-muted-foreground text-xs">{label}</span>
<pre className="bg-muted/40 overflow-x-auto rounded-md border p-3 font-mono text-xs leading-relaxed">{children}</pre>
</div>
)
}
/** Сегментированный переключатель на Tabs (вид таблицы, период). */
export function Segmented({
label,
options,
value,
onChange,
}: {
label: string
options: string[]
value: number
onChange: (index: number) => void
}) {
return (
<Tabs value={String(value)} onValueChange={(v) => onChange(Number(v))} aria-label={label} className="max-w-full">
<TabsList className="max-w-full justify-start overflow-x-auto">
{options.map((o, i) => (
<TabsTrigger key={o} value={String(i)}>
{o}
</TabsTrigger>
))}
</TabsList>
</Tabs>
)
}
/** Плашка строки с накопленной правкой: «Не сохранено» или «Будет удалено». */
export function PendingChip({ mark }: { mark: 'new' | 'dirty' | 'deleted' }) {
return (
<Badge variant={mark === 'deleted' ? 'destructive-light' : 'warning-light'} size="sm" className="whitespace-nowrap">
{mark === 'deleted' ? 'Будет удалено' : 'Не сохранено'}
</Badge>
)
}
/** Вкладки страницы как на досках: линия-подчёркивание, число — мелким индексом. */
export function PageTabsList({ children }: { children: ReactNode }) {
return (
<TabsList variant="line" className="h-10! w-full justify-start gap-0.5 overflow-x-auto border-b">
{children}
</TabsList>
)
}
export function PageTab({ value, label, count }: { value: string; label: string; count?: number | string }) {
return (
<TabsTrigger value={value} className="gap-1.5 px-4 text-sm after:-bottom-px!">
{label}
{count !== undefined ? <span className="text-muted-foreground font-mono text-xs font-normal">{count}</span> : null}
</TabsTrigger>
)
}
/** Строка настройки как на досках: слева название и описание, справа элемент управления. */
export function SettingRow({ title, description, children }: { title: string; description: string; children: ReactNode }) {
return (
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 border-b px-4 py-3.5 last:border-b-0">
<div className="flex min-w-0 flex-1 basis-64 flex-col gap-0.5">
<b className="text-sm font-medium">{title}</b>
<span className="text-muted-foreground text-[13px]">{description}</span>
</div>
<div className="shrink-0">{children}</div>
</div>
)
}
/** Frame с заголовком, итоговой плашкой справа и строками настроек (блок «form» досок). */
export function FormFrame({
title,
description,
badge,
tone = 'ok',
children,
}: {
title: string
description: string
badge: string
tone?: Tone
children: ReactNode
}) {
return (
<Frame className="[--frame-panel-header-py-adjust:4px] w-full min-w-0">
<FrameHeader className="flex-row flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 flex-col gap-0.5">
<FrameTitle>{title}</FrameTitle>
<FrameDescription>{description}</FrameDescription>
</div>
<StatusBadge tone={tone}>{badge}</StatusBadge>
</FrameHeader>
<FramePanel className="p-0">{children}</FramePanel>
</Frame>
)
}