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:
Jakob Wennberg
2026-07-23 21:29:36 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent d59e4708cf
commit 5b5ee8e429
26 changed files with 1171 additions and 24 deletions
+43
View File
@@ -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>
)
}
+100
View File
@@ -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>
)
}
+97
View File
@@ -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,
)}
</>
)
}
+11 -3
View File
@@ -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>
)}
+25
View File
@@ -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>
}
+124
View File
@@ -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,
}
+178
View File
@@ -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>
)
}