feat(ui): shared migration primitives (UI migration PR 3) (#1122)
* feat(ui): shared migration primitives (UI migration PR 3) The component kit every page migration (PR 4-8) builds on: - ContextPicker: the one-per-page chip-dropdown context scope (convention 8), right-aligned popover with checks and muted annotations - FyPicker: fiscal-year picker on ContextPicker with the same controlled API and per-company localStorage key as FiscalYearSelector, which it replaces page by page from PR 4 - SplitButton: primary + caret menu, last-used mode persisted per user via ui_state.create_mode (lib/ui-state/client, unit-tested); nav persistence refactored onto the same helper - ConfirmDialog: centered min-460px confirm-up-front dialog (convention 10) with pending state on an awaitable onConfirm - HelpPopover: 17px "?" after the H1 opening an anchored popover (convention 7); PageHeader gets a `help` slot - AttnLine: the one-ochre-sentence attention pattern (convention 6) with optional inline action; new AA-safe --attn token pair - RowStatus: chips-mark-exceptions helper (convention 5) - SlideOver: right review panel, 480px, 18px inset, rounded, veil + Esc (convention 13), with header kicker / body / footer slots - Stagger: .stagger-enter applied to the five target pages' list containers (bookkeeping, transactions, pending, invoices, supplier-invoices); structural loading.tsx added for supplier-invoices, customers, kpi, pending, deadlines No page adopts the new pickers/dialogs yet: that is PR 4-8, one page per PR against this kit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): FyPicker chip must not double the Rakenskapsar label Real fiscal periods are often named "Rakenskapsar 2026" already; only prefix the label when the period name lacks it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
d59e4708cf
commit
5b5ee8e429
@@ -0,0 +1,43 @@
|
||||
import Link from 'next/link'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface AttnLineProps {
|
||||
children: React.ReactNode
|
||||
/** Optional inline action at the end of the sentence. */
|
||||
action?: { label: string; href?: string; onClick?: () => void }
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Attention is one ochre sentence, not a banner (UI-migration convention 6):
|
||||
* a single 12.5px line in the attn tone with an optional embedded action
|
||||
* link. Max one per page.
|
||||
*/
|
||||
export function AttnLine({ children, action, className }: AttnLineProps) {
|
||||
return (
|
||||
<p className={cn('text-[12.5px] leading-5 text-attn', className)}>
|
||||
{children}
|
||||
{action && (
|
||||
<>
|
||||
{' '}
|
||||
{action.href ? (
|
||||
<Link
|
||||
href={action.href}
|
||||
className="underline underline-offset-2 hover:opacity-80"
|
||||
>
|
||||
{action.label}
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={action.onClick}
|
||||
className="underline underline-offset-2 hover:opacity-80"
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
title: string
|
||||
/**
|
||||
* Body text that DESCRIBES THE OUTCOME up front ("Bokförs som verifikat
|
||||
* A-217 med 1 250,00 kr ...") instead of the page commenting afterwards
|
||||
* (UI-migration convention 10).
|
||||
*/
|
||||
description?: React.ReactNode
|
||||
/** Optional richer body (e.g. a kontering preview) rendered below the description. */
|
||||
children?: React.ReactNode
|
||||
confirmLabel: string
|
||||
cancelLabel?: string
|
||||
/** Await-able: the dialog shows a pending state until the promise settles. */
|
||||
onConfirm: () => void | Promise<void>
|
||||
/** Terracotta confirm for destructive outcomes (avvisa, makulera). */
|
||||
destructive?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Small centered confirmation dialog (min 460px on desktop): confirm before
|
||||
* acting, describing the outcome, rather than commenting after the fact.
|
||||
*/
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
confirmLabel,
|
||||
cancelLabel,
|
||||
onConfirm,
|
||||
destructive = false,
|
||||
}: ConfirmDialogProps) {
|
||||
const tCommon = useTranslations('common')
|
||||
const [pending, setPending] = useState(false)
|
||||
|
||||
const handleConfirm = async () => {
|
||||
try {
|
||||
setPending(true)
|
||||
await onConfirm()
|
||||
onOpenChange(false)
|
||||
} finally {
|
||||
setPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => !pending && onOpenChange(next)}>
|
||||
<DialogContent className="sm:min-w-[460px] sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="font-display text-lg tracking-tight">
|
||||
{title}
|
||||
</DialogTitle>
|
||||
{description && (
|
||||
<DialogDescription className="text-[13px] leading-relaxed">
|
||||
{description}
|
||||
</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
{children}
|
||||
<DialogFooter className="gap-2 sm:gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={pending}
|
||||
>
|
||||
{cancelLabel ?? tCommon('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={destructive ? 'destructive' : 'default'}
|
||||
onClick={() => void handleConfirm()}
|
||||
disabled={pending}
|
||||
className={cn(pending && 'cursor-wait')}
|
||||
>
|
||||
{pending && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface HelpPopoverProps {
|
||||
/** Popover body: the page's help text (i18n `help_*` keys per namespace). */
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Page help behind a small "?" (UI-migration convention 7): a 17px circular
|
||||
* button right after the H1 opening a popover anchored at the button. No
|
||||
* instructional copy in the page flow.
|
||||
*/
|
||||
export function HelpPopover({ children, className }: HelpPopoverProps) {
|
||||
const tNav = useTranslations('nav')
|
||||
const [open, setOpen] = useState(false)
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const [pos, setPos] = useState({ top: 0, left: 0 })
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
if (!triggerRef.current || !panelRef.current) return
|
||||
const t = triggerRef.current.getBoundingClientRect()
|
||||
const p = panelRef.current.getBoundingClientRect()
|
||||
const margin = 8
|
||||
const left = Math.max(margin, Math.min(t.left, window.innerWidth - p.width - margin))
|
||||
const top = Math.min(t.bottom + 6, window.innerHeight - p.height - margin)
|
||||
setPos({ top, left })
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const raf = requestAnimationFrame(() => updatePosition())
|
||||
return () => cancelAnimationFrame(raf)
|
||||
}, [open, updatePosition])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
function handleClick(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement
|
||||
if (!target.isConnected) return
|
||||
if (
|
||||
(!triggerRef.current || !triggerRef.current.contains(target)) &&
|
||||
(!panelRef.current || !panelRef.current.contains(target))
|
||||
) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
document.addEventListener('keydown', handleKey)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClick)
|
||||
document.removeEventListener('keydown', handleKey)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
aria-label={tNav('help')}
|
||||
className={cn(
|
||||
'inline-flex h-[17px] w-[17px] items-center justify-center rounded-full border border-border',
|
||||
'text-[11px] leading-none text-muted-foreground transition-colors duration-150',
|
||||
'hover:border-foreground/30 hover:text-foreground',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
?
|
||||
</button>
|
||||
|
||||
{open &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="note"
|
||||
className="fixed z-[60] w-[300px] rounded-lg border border-border bg-popover p-4 text-[13px] leading-relaxed text-foreground shadow-lg animate-in fade-in slide-in-from-top-1 duration-150"
|
||||
style={{ top: pos.top, left: pos.left }}
|
||||
>
|
||||
{children}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -4,14 +4,22 @@ interface PageHeaderProps {
|
||||
title: string
|
||||
description?: string
|
||||
action?: React.ReactNode
|
||||
/**
|
||||
* Page help content, rendered as a small "?" popover right after the H1
|
||||
* (UI-migration convention 7). Pass a <HelpPopover>...</HelpPopover>.
|
||||
*/
|
||||
help?: React.ReactNode
|
||||
}
|
||||
|
||||
export function PageHeader({ title, description, action }: PageHeaderProps) {
|
||||
export function PageHeader({ title, description, action, help }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between mb-8">
|
||||
<div>
|
||||
{/* Locked at exactly 24px/32px (UI-migration convention 2) */}
|
||||
<h1 className="font-display text-2xl leading-8 tracking-tight">{title}</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Locked at exactly 24px/32px (UI-migration convention 2) */}
|
||||
<h1 className="font-display text-2xl leading-8 tracking-tight">{title}</h1>
|
||||
{help}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="text-muted-foreground mt-1 text-balance">{description}</p>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Badge, type BadgeProps } from '@/components/ui/badge'
|
||||
|
||||
export interface RowStatusDescriptor {
|
||||
label: string
|
||||
/**
|
||||
* True when the row DEVIATES from the normal state (Utkast, Förfallen,
|
||||
* Ej bokförd). Normal states render as muted text; a table where every
|
||||
* row carries the same chip is wrong (UI-migration convention 5).
|
||||
*/
|
||||
exception?: boolean
|
||||
/** Badge variant for exception states. */
|
||||
variant?: BadgeProps['variant']
|
||||
}
|
||||
|
||||
/**
|
||||
* Chips mark exceptions: renders a status as muted text for normal states
|
||||
* and as a Badge only when the row deviates. Pages define their status map
|
||||
* as `Record<Status, RowStatusDescriptor>` and pass the resolved entry.
|
||||
*/
|
||||
export function RowStatus({ status }: { status: RowStatusDescriptor }) {
|
||||
if (status.exception) {
|
||||
return <Badge variant={status.variant ?? 'warning'}>{status.label}</Badge>
|
||||
}
|
||||
return <span className="text-xs text-muted-foreground">{status.label}</span>
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
import { X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* Right slide-over for reviewing an object (UI-migration convention 13):
|
||||
* a 480px panel inset 18px from the frame edge, rounded, with veil, Esc
|
||||
* and click-outside. Create/confirm flows use the centered dialog instead;
|
||||
* this is the review surface (e.g. the Granskning detail).
|
||||
*/
|
||||
const SlideOver = DialogPrimitive.Root
|
||||
const SlideOverTrigger = DialogPrimitive.Trigger
|
||||
const SlideOverClose = DialogPrimitive.Close
|
||||
|
||||
const SlideOverContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogPrimitive.Overlay
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/30 dark:bg-black/50',
|
||||
'data-[state=open]:animate-in data-[state=open]:fade-in-0',
|
||||
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0',
|
||||
)}
|
||||
/>
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed top-[18px] right-[18px] bottom-[18px] z-50 flex w-[480px] max-w-[calc(100vw-36px)] flex-col',
|
||||
'rounded-xl border border-border bg-background shadow-[var(--shadow-lg)]',
|
||||
'duration-200 data-[state=open]:animate-in data-[state=open]:slide-in-from-right-8 data-[state=open]:fade-in-0',
|
||||
'data-[state=closed]:animate-out data-[state=closed]:slide-out-to-right-8 data-[state=closed]:fade-out-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
))
|
||||
SlideOverContent.displayName = 'SlideOverContent'
|
||||
|
||||
/**
|
||||
* Header block: kicker line (actor · risk · time), serif title, close
|
||||
* button. Body scrolls; header and footer stay put.
|
||||
*/
|
||||
function SlideOverHeader({
|
||||
kicker,
|
||||
title,
|
||||
className,
|
||||
}: {
|
||||
kicker?: React.ReactNode
|
||||
title: React.ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('flex-shrink-0 border-b border-border px-6 py-4', className)}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
{kicker && (
|
||||
<div className="mb-1 text-[11px] uppercase tracking-[0.08em] text-muted-foreground">
|
||||
{kicker}
|
||||
</div>
|
||||
)}
|
||||
<DialogPrimitive.Title className="font-display text-lg leading-6 tracking-tight">
|
||||
{title}
|
||||
</DialogPrimitive.Title>
|
||||
</div>
|
||||
<DialogPrimitive.Close
|
||||
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors duration-150 hover:bg-secondary/60 hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</DialogPrimitive.Close>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SlideOverBody({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('min-h-0 flex-1 overflow-y-auto px-6 py-4', className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SlideOverFooter({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-shrink-0 items-center justify-end gap-2 border-t border-border px-6 py-3',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
SlideOver,
|
||||
SlideOverTrigger,
|
||||
SlideOverClose,
|
||||
SlideOverContent,
|
||||
SlideOverHeader,
|
||||
SlideOverBody,
|
||||
SlideOverFooter,
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button, type ButtonProps } from '@/components/ui/button'
|
||||
import { rememberCreateMode } from '@/lib/ui-state/client'
|
||||
import { Check, ChevronDown, type LucideIcon } from 'lucide-react'
|
||||
|
||||
export interface SplitButtonOption {
|
||||
key: string
|
||||
label: string
|
||||
icon?: LucideIcon
|
||||
/** Muted second line in the menu describing what the mode does. */
|
||||
description?: string
|
||||
onSelect: () => void
|
||||
}
|
||||
|
||||
interface SplitButtonProps {
|
||||
options: SplitButtonOption[]
|
||||
/**
|
||||
* ui_state.create_mode key for last-used persistence (e.g. 'bookkeeping').
|
||||
* Omit to keep the split button stateless.
|
||||
*/
|
||||
persistKey?: string
|
||||
/**
|
||||
* Which option renders as the primary action on first paint: the
|
||||
* server-read last-used mode (resolveInitialMode) or the first option.
|
||||
*/
|
||||
initialModeKey?: string
|
||||
variant?: ButtonProps['variant']
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Primary action + caret menu (UI-migration convention 9): multiple create
|
||||
* paths collapse into one button whose primary face is the last-used mode,
|
||||
* persisted per user in user_preferences.ui_state.create_mode.
|
||||
*/
|
||||
export function SplitButton({
|
||||
options,
|
||||
persistKey,
|
||||
initialModeKey,
|
||||
variant = 'default',
|
||||
className,
|
||||
}: SplitButtonProps) {
|
||||
const tCommon = useTranslations('common')
|
||||
const [activeKey, setActiveKey] = useState(
|
||||
() => options.find((o) => o.key === initialModeKey)?.key ?? options[0]?.key,
|
||||
)
|
||||
const [open, setOpen] = useState(false)
|
||||
const caretRef = useRef<HTMLButtonElement>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const [pos, setPos] = useState({ top: 0, left: 0 })
|
||||
|
||||
const active = options.find((o) => o.key === activeKey) ?? options[0]
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
if (!caretRef.current || !menuRef.current) return
|
||||
const t = caretRef.current.getBoundingClientRect()
|
||||
const m = menuRef.current.getBoundingClientRect()
|
||||
const margin = 8
|
||||
const left = Math.max(margin, Math.min(t.right - m.width, window.innerWidth - m.width - margin))
|
||||
const top = Math.min(t.bottom + 4, window.innerHeight - m.height - margin)
|
||||
setPos({ top, left })
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const raf = requestAnimationFrame(() => updatePosition())
|
||||
return () => cancelAnimationFrame(raf)
|
||||
}, [open, updatePosition])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
function handleClick(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement
|
||||
if (!target.isConnected) return
|
||||
if (
|
||||
(!caretRef.current || !caretRef.current.contains(target)) &&
|
||||
(!menuRef.current || !menuRef.current.contains(target))
|
||||
) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
document.addEventListener('keydown', handleKey)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClick)
|
||||
document.removeEventListener('keydown', handleKey)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
if (!active) return null
|
||||
|
||||
const runOption = (option: SplitButtonOption) => {
|
||||
setActiveKey(option.key)
|
||||
if (persistKey) rememberCreateMode(persistKey, option.key)
|
||||
option.onSelect()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('inline-flex items-stretch', className)}>
|
||||
<Button
|
||||
variant={variant}
|
||||
className="rounded-r-none"
|
||||
onClick={() => runOption(active)}
|
||||
>
|
||||
{active.icon && <active.icon className="mr-1.5 h-4 w-4" />}
|
||||
{active.label}
|
||||
</Button>
|
||||
<Button
|
||||
ref={caretRef}
|
||||
variant={variant}
|
||||
aria-label={tCommon('more_options')}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="menu"
|
||||
className={cn(
|
||||
'rounded-l-none px-2',
|
||||
variant === 'default' && 'border-l border-primary-foreground/20',
|
||||
variant === 'outline' && 'border-l-0',
|
||||
variant === 'secondary' && 'border-l border-foreground/10',
|
||||
)}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{open &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
role="menu"
|
||||
className="fixed z-[60] min-w-[240px] rounded-lg border border-border bg-popover py-1 shadow-lg animate-in fade-in slide-in-from-top-1 duration-150"
|
||||
style={{ top: pos.top, left: pos.left }}
|
||||
>
|
||||
<div className="px-1">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.key}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
runOption(option)
|
||||
}}
|
||||
className={cn(
|
||||
'flex w-full items-start gap-2.5 rounded-md px-2.5 py-2 text-left transition-colors',
|
||||
'text-muted-foreground hover:bg-secondary/60 hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{option.icon && (
|
||||
<option.icon className="mt-0.5 h-4 w-4 flex-shrink-0" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-[13px] text-foreground">{option.label}</span>
|
||||
{option.description && (
|
||||
<span className="block text-[11px] leading-snug text-muted-foreground">
|
||||
{option.description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{option.key === activeKey && (
|
||||
<Check className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-primary" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user