feat(mobile): Sidebar drawer <1024px + Modal/Sheet fullscreen <880px
Per design_handoff_ordinis_mdm/README.md responsive table (line 464-465):
- ≤1024px: Sidebar becomes overlay drawer (hamburger в TopBar)
- ≤880px: Drawers и modals fullscreen (100vw × 100dvh, no border-radius)
Implementation:
src/components/layout/Sidebar.tsx
- Extracted SidebarContent (logo + nav + user block) для DRY
- Desktop <Sidebar /> wraps SidebarContent в hidden lg:flex aside
- New <MobileSidebar open onClose /> — Sheet с тем же содержимым,
260px wide, slides from left
- SidebarLink принимает onClick → drawer auto-close при navigate
src/components/layout/TopBar.tsx
- Optional onMenuClick prop — рендерит hamburger button (Menu icon
из lucide-react) с lg:hidden
- VersionBadge скрыт <md (768px) — diagnostic info, не critical UX
src/routes/__root.tsx
- useState mobileNavOpen, передаём в MobileSidebar + TopBar
src/ui/components/dialog.tsx
- SIZE_CLASSES получили prefix (arbitrary Tailwind v4
breakpoint per handoff exact 880px). На mobile <880px modal становится
fullscreen (inset-0 w-full h-full no rounded), ≥880px — centered с
translate + max-w из SIZE_CLASSES.
src/ui/components/sheet.tsx
- size variants: sm:max-w-* → min-[880px]:max-w-*. На mobile <880px
sheet берёт w-full (fullscreen drawer per handoff).
src/components/record/RecordDrawer.tsx
- max-width переключатель 520/880 теперь применяется только ≥880px.
На mobile drawer всегда fullscreen.
src/ui/components/language-switch.tsx
- Display fallback: opt.short → opt.label → uppercase id.
Previously показывал id ('ru-RU', 'en-US') когда short не был задан —
теперь использует label ('RU', 'EN') как fallback.
Browser-verified в preview viewport 375×812:
- Hamburger ☰ visible, click открывает Sheet с nav items
- Backdrop fade + slide-from-left animation
- TopBar fits в 375px (RU/EN compact, Sign in visible)
- LoadingBlock + Alert text-wrap correctly
This commit is contained in:
@@ -15,6 +15,7 @@ import {
|
|||||||
import { useAuth } from 'react-oidc-context'
|
import { useAuth } from 'react-oidc-context'
|
||||||
import { useCanMutate } from '@/auth/useCanMutate'
|
import { useCanMutate } from '@/auth/useCanMutate'
|
||||||
import { useDictionaries, useMyDrafts } from '@/api/queries'
|
import { useDictionaries, useMyDrafts } from '@/api/queries'
|
||||||
|
import { Sheet, SheetContent } from '@/ui'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -114,24 +115,47 @@ export function Sidebar() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="hidden lg:flex w-[220px] shrink-0 flex-col border-r border-line bg-surface">
|
<aside className="hidden lg:flex w-[220px] shrink-0 flex-col border-r border-line bg-surface">
|
||||||
|
<SidebarContent sections={visibleSections} auth={auth} />
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SidebarContent — внутренности (logo + nav + user). Используется в desktop
|
||||||
|
* rail (Sidebar) И в mobile drawer (MobileSidebar) — DRY.
|
||||||
|
*/
|
||||||
|
function SidebarContent({
|
||||||
|
sections,
|
||||||
|
auth,
|
||||||
|
onNavigate,
|
||||||
|
}: {
|
||||||
|
sections: NavSection[]
|
||||||
|
auth: ReturnType<typeof useAuth>
|
||||||
|
/** Called после клика на nav item — для close drawer в mobile mode. */
|
||||||
|
onNavigate?: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
{/* Logo block */}
|
{/* Logo block */}
|
||||||
<div className="h-14 flex items-center px-5 border-b border-line">
|
<div className="h-14 flex items-center px-5 border-b border-line shrink-0">
|
||||||
<Link to="/" className="font-display text-base tracking-wider text-navy hover:opacity-80 transition-opacity">
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="font-display text-base tracking-wider text-navy hover:opacity-80 transition-opacity"
|
||||||
|
onClick={onNavigate}
|
||||||
|
>
|
||||||
ORDINIS
|
ORDINIS
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Nav sections */}
|
{/* Nav sections */}
|
||||||
<nav className="flex-1 overflow-y-auto py-3 px-2 flex flex-col gap-4">
|
<nav className="flex-1 overflow-y-auto py-3 px-2 flex flex-col gap-4">
|
||||||
{visibleSections.map((section, idx) => (
|
{sections.map((section, idx) => (
|
||||||
<div key={idx} className="flex flex-col gap-0.5">
|
<div key={idx} className="flex flex-col gap-0.5">
|
||||||
{section.label && (
|
{section.label && (
|
||||||
<div className="text-cap px-3 py-1 text-mute">
|
<div className="text-cap px-3 py-1 text-mute">{section.label}</div>
|
||||||
{section.label}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{section.items.map((item) => (
|
{section.items.map((item) => (
|
||||||
<SidebarLink key={item.to} {...item} />
|
<SidebarLink key={item.to} {...item} onClick={onNavigate} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -139,7 +163,7 @@ export function Sidebar() {
|
|||||||
|
|
||||||
{/* User block — sticky bottom */}
|
{/* User block — sticky bottom */}
|
||||||
{auth.isAuthenticated && auth.user?.profile && (
|
{auth.isAuthenticated && auth.user?.profile && (
|
||||||
<div className="border-t border-line px-3 py-3 flex items-center gap-2">
|
<div className="border-t border-line px-3 py-3 flex items-center gap-2 shrink-0">
|
||||||
<div className="size-8 rounded-full bg-accent text-on-accent inline-flex items-center justify-center text-cell font-semibold">
|
<div className="size-8 rounded-full bg-accent text-on-accent inline-flex items-center justify-center text-cell font-semibold">
|
||||||
{String(
|
{String(
|
||||||
auth.user.profile.preferred_username ||
|
auth.user.profile.preferred_username ||
|
||||||
@@ -164,11 +188,90 @@ export function Sidebar() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</aside>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarLink({ to, label, icon: Icon, badge }: NavItem) {
|
/**
|
||||||
|
* MobileSidebar — Sheet с тем же содержимым что desktop Sidebar. Открывается
|
||||||
|
* через hamburger button в TopBar (<1024px), закрывается на: backdrop click,
|
||||||
|
* Escape, или клик на любой nav item (auto-close, привычная mobile UX).
|
||||||
|
*
|
||||||
|
* <p>Hooks logic дублируется (auth, dictsCount etc.) — нужно для filter
|
||||||
|
* auth-required sections. Можно вынести в useSidebarSections() hook
|
||||||
|
* следующим refactor.
|
||||||
|
*/
|
||||||
|
export function MobileSidebar({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const auth = useAuth()
|
||||||
|
const canMutate = useCanMutate()
|
||||||
|
const dictsQuery = useDictionaries()
|
||||||
|
const myDrafts = useMyDrafts(0, 1)
|
||||||
|
|
||||||
|
const dictsCount = dictsQuery.data?.length ?? 0
|
||||||
|
const myDraftsCount = myDrafts.data?.totalElements ?? 0
|
||||||
|
const reviewsCount = 0
|
||||||
|
|
||||||
|
const sections: NavSection[] = [
|
||||||
|
{
|
||||||
|
items: [
|
||||||
|
{ to: '/', label: t('nav.home'), icon: Home },
|
||||||
|
{
|
||||||
|
to: '/dictionaries',
|
||||||
|
label: t('nav.dictionaries'),
|
||||||
|
icon: Database,
|
||||||
|
badge: dictsCount > 0 ? dictsCount : undefined,
|
||||||
|
},
|
||||||
|
{ to: '/search', label: t('nav.search'), icon: Search },
|
||||||
|
{ to: '/graph', label: t('nav.graph'), icon: GitBranch },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('nav.section.workflow'),
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
to: '/my-drafts',
|
||||||
|
label: t('nav.myDrafts'),
|
||||||
|
icon: FileText,
|
||||||
|
badge: myDraftsCount > 0 ? myDraftsCount : undefined,
|
||||||
|
authRequired: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: '/reviews',
|
||||||
|
label: t('nav.reviews'),
|
||||||
|
icon: ClipboardList,
|
||||||
|
badge: reviewsCount > 0 ? reviewsCount : undefined,
|
||||||
|
authRequired: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('nav.section.admin'),
|
||||||
|
items: [
|
||||||
|
{ to: '/audit', label: t('nav.audit'), icon: ScrollText, authRequired: true },
|
||||||
|
{ to: '/outbox', label: t('nav.outbox'), icon: Inbox, authRequired: true },
|
||||||
|
{ to: '/webhooks', label: t('nav.webhooks'), icon: Webhook, authRequired: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const visibleSections = sections
|
||||||
|
.map((s) => ({ ...s, items: s.items.filter((i) => !i.authRequired || canMutate) }))
|
||||||
|
.filter((s) => s.items.length > 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
||||||
|
<SheetContent
|
||||||
|
side="left"
|
||||||
|
size="md"
|
||||||
|
className="!w-[260px] !max-w-[260px] !p-0 !gap-0 flex flex-col bg-surface"
|
||||||
|
>
|
||||||
|
<SidebarContent sections={visibleSections} auth={auth} onNavigate={onClose} />
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarLink({ to, label, icon: Icon, badge, onClick }: NavItem & { onClick?: () => void }) {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const active =
|
const active =
|
||||||
to === '/'
|
to === '/'
|
||||||
@@ -178,6 +281,7 @@ function SidebarLink({ to, label, icon: Icon, badge }: NavItem) {
|
|||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to={to}
|
to={to}
|
||||||
|
onClick={onClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
// text-body = 13px workhorse per handoff (см. styles.css @utility).
|
// text-body = 13px workhorse per handoff (см. styles.css @utility).
|
||||||
'group flex items-center gap-2.5 px-3 py-1.5 rounded-md text-body transition-colors',
|
'group flex items-center gap-2.5 px-3 py-1.5 rounded-md text-body transition-colors',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { AuthBadge } from '@/auth/AuthBadge'
|
|||||||
import { ThemeSwitch } from '@/components/layout/ThemeSwitch'
|
import { ThemeSwitch } from '@/components/layout/ThemeSwitch'
|
||||||
import { VersionBadge } from '@/components/version/VersionBadge'
|
import { VersionBadge } from '@/components/version/VersionBadge'
|
||||||
import { SearchInput } from '@/ui/components/search-input'
|
import { SearchInput } from '@/ui/components/search-input'
|
||||||
|
import { Menu } from 'lucide-react'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -21,7 +22,7 @@ const LANG_OPTIONS = [
|
|||||||
{ id: 'en-US', label: 'EN' },
|
{ id: 'en-US', label: 'EN' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export function TopBar() {
|
export function TopBar({ onMenuClick }: { onMenuClick?: () => void }) {
|
||||||
const { t, i18n } = useTranslation()
|
const { t, i18n } = useTranslation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [searchValue, setSearchValue] = useState('')
|
const [searchValue, setSearchValue] = useState('')
|
||||||
@@ -59,6 +60,18 @@ export function TopBar() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="h-14 shrink-0 border-b border-line bg-surface flex items-center px-4 sm:px-6 gap-3">
|
<header className="h-14 shrink-0 border-b border-line bg-surface flex items-center px-4 sm:px-6 gap-3">
|
||||||
|
{/* Hamburger (lg:hidden) — toggle MobileSidebar */}
|
||||||
|
{onMenuClick && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t('nav.menu', { defaultValue: 'Меню' })}
|
||||||
|
onClick={onMenuClick}
|
||||||
|
className="lg:hidden -ml-1 p-1.5 rounded-sm text-ink-2 hover:text-ink hover:bg-surface-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring transition-colors"
|
||||||
|
>
|
||||||
|
<Menu size={20} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Breadcrumb — left */}
|
{/* Breadcrumb — left */}
|
||||||
<nav className="flex items-center gap-1.5 text-body min-w-0">
|
<nav className="flex items-center gap-1.5 text-body min-w-0">
|
||||||
{breadcrumb.map((crumb, i) => {
|
{breadcrumb.map((crumb, i) => {
|
||||||
@@ -105,7 +118,10 @@ export function TopBar() {
|
|||||||
>
|
>
|
||||||
{t('nav.docs')}
|
{t('nav.docs')}
|
||||||
</a>
|
</a>
|
||||||
<VersionBadge />
|
{/* VersionBadge hide на narrow viewport — diagnostic info, не critical UX */}
|
||||||
|
<span className="hidden md:inline-flex">
|
||||||
|
<VersionBadge />
|
||||||
|
</span>
|
||||||
<ThemeSwitch />
|
<ThemeSwitch />
|
||||||
<LanguageSwitch
|
<LanguageSwitch
|
||||||
value={i18n.language}
|
value={i18n.language}
|
||||||
|
|||||||
@@ -129,9 +129,9 @@ export function RecordDrawer({
|
|||||||
'fixed inset-y-0 right-0 z-50 h-full bg-surface shadow-2xl border-l border-line',
|
'fixed inset-y-0 right-0 z-50 h-full bg-surface shadow-2xl border-l border-line',
|
||||||
'flex flex-col',
|
'flex flex-col',
|
||||||
'transition-[max-width] duration-200 ease-out',
|
'transition-[max-width] duration-200 ease-out',
|
||||||
// Mobile <= 880px: fullscreen per handoff.
|
// Mobile <880px: fullscreen per handoff (line 465).
|
||||||
'w-full',
|
'w-full',
|
||||||
wide ? 'sm:max-w-[880px]' : 'sm:max-w-[520px]',
|
wide ? 'min-[880px]:max-w-[880px]' : 'min-[880px]:max-w-[520px]',
|
||||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||||
'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right',
|
'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right',
|
||||||
'data-[state=closed]:duration-200 data-[state=open]:duration-300',
|
'data-[state=closed]:duration-200 data-[state=open]:duration-300',
|
||||||
|
|||||||
@@ -1,33 +1,41 @@
|
|||||||
import { createRootRouteWithContext, Outlet } from '@tanstack/react-router'
|
import { createRootRouteWithContext, Outlet } from '@tanstack/react-router'
|
||||||
import type { QueryClient } from '@tanstack/react-query'
|
import type { QueryClient } from '@tanstack/react-query'
|
||||||
import { Sidebar } from '@/components/layout/Sidebar'
|
import { useState } from 'react'
|
||||||
|
import { Sidebar, MobileSidebar } from '@/components/layout/Sidebar'
|
||||||
import { TopBar } from '@/components/layout/TopBar'
|
import { TopBar } from '@/components/layout/TopBar'
|
||||||
import { UpdateBanner } from '@/components/version/UpdateBanner'
|
import { UpdateBanner } from '@/components/version/UpdateBanner'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Root layout (Stage 3.1 design handoff):
|
* Root layout (Stage 3.1 + 3.x mobile responsive):
|
||||||
*
|
*
|
||||||
* ┌─ Sidebar 220px (lg:flex) ──┬─ main column (flex-1) ────────────┐
|
* ≥1024px (lg+):
|
||||||
* │ logo │ UpdateBanner (when applicable) │
|
* ┌─ Sidebar 220px ──┬─ main column (flex-1) ────┐
|
||||||
* │ nav rail с counters │ TopBar h=56 sticky │
|
* │ logo │ TopBar (sticky) │
|
||||||
* │ user profile │ Outlet (route content) │
|
* │ nav rail │ Outlet (route content) │
|
||||||
* └────────────────────────────┴───────────────────────────────────┘
|
* │ user profile │ │
|
||||||
|
* └──────────────────┴───────────────────────────┘
|
||||||
*
|
*
|
||||||
* Responsive: на <1024px sidebar скрывается (hidden lg:flex). Main grid
|
* <1024px:
|
||||||
* растягивается на весь viewport. Mobile hamburger drawer — TODO Stage 3.x.
|
* ┌─ main column (flex-1) ────────────────────────┐
|
||||||
|
* │ TopBar [☰] (hamburger toggles MobileSidebar) │
|
||||||
|
* │ Outlet │
|
||||||
|
* └───────────────────────────────────────────────┘
|
||||||
|
* MobileSidebar — overlay Sheet (slide-in left, 260px).
|
||||||
*/
|
*/
|
||||||
export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
|
export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
|
||||||
component: RootLayout,
|
component: RootLayout,
|
||||||
})
|
})
|
||||||
|
|
||||||
function RootLayout() {
|
function RootLayout() {
|
||||||
|
const [mobileNavOpen, setMobileNavOpen] = useState(false)
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex flex-col bg-bg text-ink overflow-hidden">
|
<div className="h-screen flex flex-col bg-bg text-ink overflow-hidden">
|
||||||
<UpdateBanner />
|
<UpdateBanner />
|
||||||
<div className="flex-1 flex min-h-0">
|
<div className="flex-1 flex min-h-0">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
|
<MobileSidebar open={mobileNavOpen} onClose={() => setMobileNavOpen(false)} />
|
||||||
<div className="flex-1 flex flex-col min-w-0">
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
<TopBar />
|
<TopBar onMenuClick={() => setMobileNavOpen(true)} />
|
||||||
<main className="flex-1 overflow-y-auto px-4 sm:px-6 py-4 sm:py-6">
|
<main className="flex-1 overflow-y-auto px-4 sm:px-6 py-4 sm:py-6">
|
||||||
<div className="max-w-7xl mx-auto w-full">
|
<div className="max-w-7xl mx-auto w-full">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|||||||
@@ -49,12 +49,15 @@ export const DialogOverlay = React.forwardRef<
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Mobile <880px = fullscreen (per handoff line 465); ≥880px = centered с max-w.
|
||||||
|
// Используем arbitrary `min-[880px]:` (Tailwind v4 inline breakpoint) для точной
|
||||||
|
// границы — handoff specifically wants 880, не стандартный md (768) или lg (1024).
|
||||||
const SIZE_CLASSES: Record<string, string> = {
|
const SIZE_CLASSES: Record<string, string> = {
|
||||||
sm: 'max-w-[440px]',
|
sm: 'min-[880px]:max-w-[440px]',
|
||||||
md: 'max-w-[560px]',
|
md: 'min-[880px]:max-w-[560px]',
|
||||||
lg: 'max-w-[720px]',
|
lg: 'min-[880px]:max-w-[720px]',
|
||||||
xl: 'max-w-[880px]',
|
xl: 'min-[880px]:max-w-[880px]',
|
||||||
full: 'max-w-full max-h-full sm:max-w-[920px]',
|
full: 'min-[880px]:max-w-[920px]',
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DialogContentProps = React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
export type DialogContentProps = React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||||
@@ -72,7 +75,12 @@ export const DialogContent = React.forwardRef<
|
|||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'fixed left-1/2 top-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 border border-line bg-surface p-6 shadow-2xl duration-200 sm:rounded-lg',
|
'fixed z-50 grid gap-4 border border-line bg-surface p-6 shadow-2xl duration-200',
|
||||||
|
// Mobile <880px (per handoff): fullscreen 100vw × 100dvh, no corners,
|
||||||
|
// no centered transform. Editor body становится single-column natively.
|
||||||
|
'inset-0 w-full h-full',
|
||||||
|
// Desktop ≥880px: centered modal с rounded corners + max-w из SIZE_CLASSES.
|
||||||
|
'min-[880px]:left-1/2 min-[880px]:top-1/2 min-[880px]:inset-auto min-[880px]:h-auto min-[880px]:w-full min-[880px]:-translate-x-1/2 min-[880px]:-translate-y-1/2 min-[880px]:rounded-lg',
|
||||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
'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-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||||
|
|||||||
@@ -61,7 +61,10 @@ export function LanguageSwitch({
|
|||||||
: 'text-mute hover:text-ink hover:bg-surface-2',
|
: 'text-mute hover:text-ink hover:bg-surface-2',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{opt.short ?? v.toUpperCase()}
|
{/* Display priority: short → label (if compact like "RU"/"EN") →
|
||||||
|
* uppercase id. Label predominates когда caller передал human
|
||||||
|
* label типа "RU"/"EN" в options. */}
|
||||||
|
{opt.short ?? opt.label ?? v.toUpperCase()}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -49,11 +49,13 @@ const sheetVariants = cva(
|
|||||||
left: 'inset-y-0 left-0 h-full border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left',
|
left: 'inset-y-0 left-0 h-full border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left',
|
||||||
right: 'inset-y-0 right-0 h-full border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right',
|
right: 'inset-y-0 right-0 h-full border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right',
|
||||||
},
|
},
|
||||||
|
// Per handoff (line 465): drawers fullscreen <880px, max-w на ≥880px.
|
||||||
|
// arbitrary breakpoint `min-[880px]:` гарантирует точную границу.
|
||||||
size: {
|
size: {
|
||||||
sm: 'sm:max-w-sm w-full',
|
sm: 'min-[880px]:max-w-sm w-full',
|
||||||
md: 'sm:max-w-[520px] w-full',
|
md: 'min-[880px]:max-w-[520px] w-full',
|
||||||
lg: 'sm:max-w-[720px] w-full',
|
lg: 'min-[880px]:max-w-[720px] w-full',
|
||||||
xl: 'sm:max-w-[880px] w-full',
|
xl: 'min-[880px]:max-w-[880px] w-full',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: { side: 'right', size: 'md' },
|
defaultVariants: { side: 'right', size: 'md' },
|
||||||
|
|||||||
Reference in New Issue
Block a user