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
+1
View File
@@ -338,3 +338,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-07-23] UI-migration plan authored (dev_docs/ui_migration_plan.md): shell-first sequence (tokens/nav/primitives) then Bokforing/Transaktioner/Granskning/Kund+Lev-fakturor; concept artifact is the reference; 14 locked design conventions codified: chose PR-per-page over big-bang to keep founder visual sign-off per merge.
[2026-07-23] Frame layout (UI-migration PR 1) is md:-gated and the panel owns desktop scroll: mobile keeps document flow + bottom nav (concept is desktop-first), and since Next's window scroll-to-top never fires for an inner scroll container, MainContainer resets panel scroll on route change. Button default size drops fixed h-10 for natural pill height (7px/16px padding per locked convention 3); sm/lg/icon keep their heights.
[2026-07-23] Nav PR 2: ui_state persisted as one jsonb bag on user_preferences (founder-approved migration 20260723120000) rather than per-preference columns: cosmetic, never load-bearing, grows with PR 3/4 split-button modes. Sidebar width driven by an inline --nav-w CSS variable on #dash-shell instead of a [data-nav-collapsed] attribute rule: the Tailwind 4/Lightning CSS pipeline silently dropped the top-level attribute-selector rule from compiled output, and the inline variable is pipeline-proof. Register/Bokslut folds default closed (concept tidiness), forced open by an active child route. Discord-community row skipped: no invite URL exists in the repo; add when one lands.
[2026-07-23] PR 3 primitives: new --attn token pair (38 50% 34% light / 38 45% 62% dark) for the one-sentence AttnLine instead of reusing --warning: the warning tone fails WCAG AA as 12.5px body text on the page background; chips/charts keep --warning. Stagger applied via className on the five target pages' DataList/TableBody containers (plan item 7), not baked into the DataList primitive: remaining pages adopt it in their own migration PRs where their skeletons are aligned at the same time.
+31
View File
@@ -0,0 +1,31 @@
import { Skeleton } from '@/components/ui/skeleton'
export default function CustomersLoading() {
return (
<div className="space-y-8">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<Skeleton className="h-8 w-32" />
<div className="flex gap-2">
<Skeleton className="h-9 w-28 rounded-full" />
<Skeleton className="h-9 w-28 rounded-full" />
</div>
</div>
<Skeleton className="h-10 w-full" />
<div className="rounded-lg border border-border">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div
key={i}
className="flex items-center justify-between border-b border-border/60 px-4 py-3 last:border-b-0"
>
<Skeleton className="h-4 w-40" />
<div className="flex items-center gap-6">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-3 w-32" />
<Skeleton className="h-3 w-20" />
</div>
</div>
))}
</div>
</div>
)
}
+27
View File
@@ -0,0 +1,27 @@
import { Skeleton } from '@/components/ui/skeleton'
export default function DeadlinesLoading() {
return (
<div className="space-y-8">
<Skeleton className="h-8 w-44" />
<div className="space-y-3">
{[1, 2, 3, 4, 5].map((i) => (
<div
key={i}
className="flex items-start gap-4 rounded-lg border border-border p-4"
>
<div className="w-12 space-y-1">
<Skeleton className="h-6 w-10" />
<Skeleton className="h-3 w-12" />
</div>
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-56" />
<Skeleton className="h-3 w-72" />
</div>
<Skeleton className="h-5 w-16" />
</div>
))}
</div>
</div>
)
}
+1 -1
View File
@@ -273,7 +273,7 @@ export default function InvoicesPage() {
</Tabs>
</div>
<DataList>
<DataList className="stagger-enter">
{isLoading ? (
[1, 2, 3].map((i) => (
<div key={i} className="flex items-center gap-3 px-4 py-3">
+25
View File
@@ -0,0 +1,25 @@
import { Skeleton } from '@/components/ui/skeleton'
export default function KpiLoading() {
return (
<div className="space-y-8">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<Skeleton className="h-8 w-36" />
<Skeleton className="h-9 w-44 rounded-full" />
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="rounded-lg border border-border p-4">
<Skeleton className="mb-3 h-3 w-24" />
<Skeleton className="mb-2 h-7 w-32" />
<Skeleton className="h-3 w-40" />
</div>
))}
</div>
<div className="rounded-lg border border-border p-6">
<Skeleton className="mb-4 h-4 w-40" />
<Skeleton className="h-48 w-full" />
</div>
</div>
)
}
+30
View File
@@ -0,0 +1,30 @@
import { Skeleton } from '@/components/ui/skeleton'
export default function PendingLoading() {
return (
<div className="space-y-8">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<Skeleton className="h-8 w-40" />
<Skeleton className="h-9 w-36 rounded-full" />
</div>
<div className="rounded-lg border border-border">
{[1, 2, 3, 4].map((i) => (
<div
key={i}
className="flex items-center gap-4 border-b border-border/60 px-4 py-4 last:border-b-0"
>
<Skeleton className="h-7 w-7 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-64" />
<Skeleton className="h-3 w-40" />
</div>
<div className="flex items-center gap-2">
<Skeleton className="h-8 w-24 rounded-full" />
<Skeleton className="h-8 w-24 rounded-full" />
</div>
</div>
))}
</div>
</div>
)
}
+1 -1
View File
@@ -1055,7 +1055,7 @@ export default function PendingOperationsPage() {
</DropdownMenu>
</div>
<DataList>
<DataList className="stagger-enter">
{showBulkControls && bulkEligible.length > 0 && (
<DataListHeader>
<div className="flex items-center gap-2">
@@ -0,0 +1,33 @@
import { Skeleton } from '@/components/ui/skeleton'
export default function SupplierInvoicesLoading() {
return (
<div className="space-y-8">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-9 w-36 rounded-full" />
</div>
<div className="flex items-center gap-2">
<Skeleton className="h-9 w-72" />
<Skeleton className="h-9 flex-1" />
</div>
<div className="rounded-lg border border-border">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div
key={i}
className="flex items-center justify-between border-b border-border/60 px-4 py-3 last:border-b-0"
>
<div className="space-y-2">
<Skeleton className="h-4 w-44" />
<Skeleton className="h-3 w-28" />
</div>
<div className="flex items-center gap-4">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-5 w-16" />
</div>
</div>
))}
</div>
</div>
)
}
+1 -1
View File
@@ -215,7 +215,7 @@ export default function SupplierInvoicesPage() {
<TableHead>{t('th_status')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableBody className="stagger-enter">
{filteredInvoices.map((inv) => (
<TableRow key={inv.id}>
<TableCell className="tabular-nums">{inv.arrival_number}</TableCell>
+2 -2
View File
@@ -2220,7 +2220,7 @@ export default function TransactionsPage() {
{/* Content based on mode */}
{isLoading ? (
<DataList>
<DataList className="stagger-enter">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center gap-3 px-4 py-3">
<Skeleton className="h-5 w-5 rounded" />
@@ -2239,7 +2239,7 @@ export default function TransactionsPage() {
onCreateTransaction={() => setIsDialogOpen(true)}
/>
) : (
<DataList>
<DataList className="stagger-enter">
{(sourceFilter !== 'all'
|| (skvUnmatched.length > 0 && uncategorizedTransactions.length > 0)) && (
<DataListHeader>
+7
View File
@@ -63,6 +63,10 @@
--warning: 38 55% 50%;
--warning-foreground: 0 0% 9%;
/* Attn: darker ochre for the one-sentence attention line (.attn / AttnLine),
derived from --warning but AA-safe as small text on the page background. */
--attn: 38 50% 34%;
--warm-accent: 38 45% 52%;
/* Chart colors */
@@ -127,6 +131,8 @@
--warning: 38 50% 55%;
--warning-foreground: 38 50% 90%;
--attn: 38 45% 62%;
--warm-accent: 38 42% 58%;
--chart-1: 155 22% 48%;
@@ -164,6 +170,7 @@
--color-success-foreground: hsl(var(--success-foreground));
--color-warning: hsl(var(--warning));
--color-warning-foreground: hsl(var(--warning-foreground));
--color-attn: hsl(var(--attn));
--color-warm-accent: hsl(var(--warm-accent));
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
+4 -4
View File
@@ -672,7 +672,7 @@ export default function JournalEntryList() {
// Verifikat/Utkast toggle stays reachable.
if (!loading && entries.length === 0 && !hasActiveFilters && listMode === 'committed' && draftCount === 0) {
return (
<DataList>
<DataList className="stagger-enter">
<DataListEmpty
icon={<BookOpen className="h-6 w-6" />}
title={t('empty_title')}
@@ -933,7 +933,7 @@ export default function JournalEntryList() {
</div>
{loading ? (
<DataList>
<DataList className="stagger-enter">
<DataListLoading />
</DataList>
) : filteredEntries.length === 0 ? (
@@ -941,7 +941,7 @@ export default function JournalEntryList() {
// filtered committed view with no matches, or a committed view with no
// posted entries yet (but drafts exist, hence we got here, not the
// pristine early return above).
<DataList>
<DataList className="stagger-enter">
<DataListEmpty
icon={
listMode === 'drafts' || !hasActiveFilters ? (
@@ -967,7 +967,7 @@ export default function JournalEntryList() {
/>
</DataList>
) : (
<DataList>
<DataList className="stagger-enter">
{/* Batch-mark "Inget underlag krävs": select-all + contextual action bar,
rendered as the list header so it reads as part of the ledger rather
than a detached box above it. */}
+158
View File
@@ -0,0 +1,158 @@
'use client'
import { useState, useRef, useEffect, useCallback } from 'react'
import { createPortal } from 'react-dom'
import { cn } from '@/lib/utils'
import { Check, ChevronDown } from 'lucide-react'
export interface ContextPickerItem {
id: string
label: string
/** Muted right-side note on the row, e.g. "stängt" for a closed fiscal year. */
annotation?: string
disabled?: boolean
}
interface ContextPickerProps {
items: ContextPickerItem[]
/** Selected item id. */
value: string | null
onChange: (id: string) => void
/** Chip text, e.g. "Räkenskapsår 2026" or "Alla källor · 24 300 kr". */
triggerLabel: string
disabled?: boolean
ariaLabel?: string
className?: string
}
/**
* The page context picker (UI-migration convention 8): a chip-dropdown that
* scopes the page to a fiscal year, account or source. One per page, far
* right in the toolbar. A chip that looks like a picker must be a picker;
* this is that picker.
*/
export function ContextPicker({
items,
value,
onChange,
triggerLabel,
disabled = false,
ariaLabel,
className,
}: ContextPickerProps) {
const [open, setOpen] = useState(false)
const triggerRef = useRef<HTMLButtonElement>(null)
const listRef = useRef<HTMLDivElement>(null)
const [pos, setPos] = useState({ top: 0, left: 0 })
const updatePosition = useCallback(() => {
if (!triggerRef.current || !listRef.current) return
const t = triggerRef.current.getBoundingClientRect()
const l = listRef.current.getBoundingClientRect()
const margin = 8
// Right-aligned under the chip (the picker lives far right in the
// toolbar), clamped to the viewport.
const left = Math.max(margin, Math.min(t.right - l.width, window.innerWidth - l.width - margin))
const top = Math.min(t.bottom + 4, window.innerHeight - l.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)) &&
(!listRef.current || !listRef.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)}
disabled={disabled}
aria-expanded={open}
aria-haspopup="listbox"
aria-label={ariaLabel}
className={cn(
'inline-flex items-center gap-1.5 whitespace-nowrap rounded-full border border-border px-3 py-[5px] text-[13px]',
'text-foreground transition-colors duration-150',
disabled
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-secondary/60 cursor-pointer',
className,
)}
>
<span className="truncate max-w-[220px]">{triggerLabel}</span>
<ChevronDown className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
</button>
{open &&
createPortal(
<div
ref={listRef}
role="listbox"
className="fixed z-[60] min-w-[220px] max-w-[320px] 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="max-h-72 overflow-y-auto px-1">
{items.map((item) => (
<button
key={item.id}
type="button"
role="option"
aria-selected={item.id === value}
disabled={item.disabled}
onClick={() => {
onChange(item.id)
setOpen(false)
}}
className={cn(
'flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-[13px] leading-snug transition-colors',
item.disabled
? 'text-muted-foreground/40 cursor-not-allowed'
: item.id === value
? 'bg-secondary/60 text-foreground'
: 'text-muted-foreground hover:bg-secondary/60 hover:text-foreground',
)}
>
<span className="min-w-0 flex-1 truncate">{item.label}</span>
{item.annotation && (
<span className="flex-shrink-0 text-[11px] text-muted-foreground/70">
{item.annotation}
</span>
)}
{item.id === value && (
<Check className="h-3.5 w-3.5 flex-shrink-0 text-primary" />
)}
</button>
))}
</div>
</div>,
document.body,
)}
</>
)
}
+162
View File
@@ -0,0 +1,162 @@
'use client'
import { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import { useCompany } from '@/contexts/CompanyContext'
import { ContextPicker } from '@/components/common/ContextPicker'
import {
STORAGE_KEY_PREFIX,
ALL_YEARS_VALUE,
} from '@/components/common/FiscalYearSelector'
import type { FiscalPeriod } from '@/types'
interface FyPickerProps {
/** Current selection. `null` means "all years": no filter applied. */
value: string | null
/**
* Called with the selected period id (or null for "all years") and the
* matching FiscalPeriod so callers avoid an extra fetch.
*/
onChange: (periodId: string | null, period?: FiscalPeriod | null) => void
/** Include an "Alla räkenskapsår" option that clears the filter. */
includeAllOption?: boolean
/** Only show periods that have started (Reports-style filter). */
hideFuturePeriods?: boolean
/** Fires once after the initial period load completes. */
onReady?: () => void
/** Server-loaded periods for the first render, scoped to initialCompanyId. */
initialPeriods?: FiscalPeriod[]
initialCompanyId?: string | null
className?: string
}
function preparePeriods(periods: FiscalPeriod[], hideFuturePeriods: boolean): FiscalPeriod[] {
const today = new Date().toISOString().split('T')[0]
return periods
.filter((p) => !hideFuturePeriods || p.period_start <= today)
.sort((a, b) => b.period_start.localeCompare(a.period_start))
}
/**
* Fiscal-year context picker (UI-migration plan PR 3): the chip-dropdown
* "Räkenskapsår 2026" with a check on the active choice and closed/locked
* years annotated. Same controlled API and per-company localStorage
* persistence as FiscalYearSelector, which it replaces page by page from
* PR 4 on.
*/
export function FyPicker({
value,
onChange,
includeAllOption = true,
hideFuturePeriods = false,
onReady,
initialPeriods,
initialCompanyId,
className,
}: FyPickerProps) {
const { company } = useCompany()
const t = useTranslations('fiscal_year')
const canUseInitial = initialCompanyId === company?.id && initialPeriods !== undefined
const [periods, setPeriods] = useState<FiscalPeriod[]>(() =>
canUseInitial ? preparePeriods(initialPeriods, hideFuturePeriods) : [],
)
const [loaded, setLoaded] = useState(canUseInitial)
useEffect(() => {
if (!company?.id) {
onReady?.()
return
}
let cancelled = false
;(async () => {
let fetched: FiscalPeriod[]
if (initialCompanyId === company.id && initialPeriods !== undefined) {
fetched = preparePeriods(initialPeriods, hideFuturePeriods)
} else {
const res = await fetch('/api/bookkeeping/fiscal-periods')
if (!res.ok) {
if (!cancelled) {
setLoaded(true)
onReady?.()
}
return
}
const { data } = await res.json()
fetched = preparePeriods(data || [], hideFuturePeriods)
}
if (cancelled) return
setPeriods(fetched)
setLoaded(true)
// Restore last selection (same key as FiscalYearSelector so pages keep
// their scope when the picker swaps in).
if (value === null && typeof window !== 'undefined') {
const stored = window.localStorage.getItem(STORAGE_KEY_PREFIX + company.id)
if (stored === ALL_YEARS_VALUE) {
if (includeAllOption) onChange(null, null)
else if (fetched.length > 0) onChange(fetched[0].id, fetched[0])
} else if (stored && fetched.some((p) => p.id === stored)) {
onChange(stored, fetched.find((p) => p.id === stored) ?? null)
} else if (!includeAllOption && fetched.length > 0) {
onChange(fetched[0].id, fetched[0])
}
}
onReady?.()
})()
return () => {
cancelled = true
}
// onReady is a lifecycle callback: fire once per load, not on parent
// re-renders that re-create it.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [company?.id, hideFuturePeriods, includeAllOption, initialCompanyId, initialPeriods])
const handleChange = (id: string) => {
const nextId = id === ALL_YEARS_VALUE ? null : id
if (company?.id && typeof window !== 'undefined') {
window.localStorage.setItem(STORAGE_KEY_PREFIX + company.id, nextId ?? ALL_YEARS_VALUE)
}
onChange(nextId, nextId ? periods.find((p) => p.id === nextId) ?? null : null)
}
const annotationFor = (p: FiscalPeriod) =>
p.locked_at ? t('badge_locked').toLowerCase() : p.is_closed ? t('badge_closed').toLowerCase() : undefined
const selected = value ? periods.find((p) => p.id === value) : null
// Real period names often already read "Räkenskapsår 2026"; only prefix
// the label when the name is a bare year/name so the chip never doubles up.
const chipLabel = (p: FiscalPeriod) =>
p.name.toLowerCase().includes(t('label').toLowerCase())
? p.name
: `${t('label')} ${p.name}`
const triggerLabel = selected
? chipLabel(selected)
: includeAllOption
? t('all_years')
: loaded
? t('placeholder')
: t('loading')
const items = [
...(includeAllOption ? [{ id: ALL_YEARS_VALUE, label: t('all_years') }] : []),
...periods.map((p) => ({
id: p.id,
label: p.name,
annotation: annotationFor(p),
})),
]
return (
<ContextPicker
items={items}
value={value ?? (includeAllOption ? ALL_YEARS_VALUE : null)}
onChange={handleChange}
triggerLabel={triggerLabel}
disabled={!loaded || periods.length === 0}
ariaLabel={t('label')}
className={className}
/>
)
}
+1 -10
View File
@@ -58,6 +58,7 @@ import { useAgentSheet } from '@/components/agent/AgentSheetProvider'
import { useCompany } from '@/contexts/CompanyContext'
import { useRealtimeSupabase } from '@/lib/hooks/use-realtime-supabase'
import { useWorklistBadges } from '@/lib/hooks/use-worklist-badges'
import { persistUiState } from '@/lib/ui-state/client'
import { EXTENSION_REQUIRED_CAPABILITY, type CapabilityKey } from '@/lib/entitlements/keys'
import type { EntityType, UserUiState } from '@/types'
@@ -314,16 +315,6 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
const isItemEnabled = (href: string) => hasCompany || ALWAYS_ENABLED.has(href)
type ExpandableGroup = Exclude<GroupKey, 'top'>
// Persist a partial ui_state patch. Fire-and-forget: this is cosmetic
// preference data; a lost write self-corrects on the next toggle.
const persistUiState = (patch: Partial<UserUiState>) => {
void fetch('/api/user/ui-state', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
}).catch(() => {})
}
// Sidebar collapse (64px icon rail). The width is CSS-variable-driven:
// #dash-shell sets --nav-w inline (server-rendered from ui_state), and
// both the aside and <main> read it, so one property flip resizes the
+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>
)
}
+61
View File
@@ -0,0 +1,61 @@
/**
* Tests for the client-side ui_state helpers: persistence POST shape,
* silent failure, and last-used split-button mode resolution.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { persistUiState, rememberCreateMode, resolveInitialMode } from '../client'
const fetchMock = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
fetchMock.mockResolvedValue({ ok: true })
vi.stubGlobal('fetch', fetchMock)
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('persistUiState', () => {
it('POSTs the patch to /api/user/ui-state', () => {
persistUiState({ nav_collapsed: true })
expect(fetchMock).toHaveBeenCalledWith('/api/user/ui-state', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nav_collapsed: true }),
})
})
it('swallows network failures', () => {
fetchMock.mockRejectedValue(new Error('offline'))
expect(() => persistUiState({ nav_collapsed: false })).not.toThrow()
})
})
describe('rememberCreateMode', () => {
it('nests the mode under the surface key', () => {
rememberCreateMode('bookkeeping', 'mall')
const body = JSON.parse(fetchMock.mock.calls[0][1].body)
expect(body).toEqual({ create_mode: { bookkeeping: 'mall' } })
})
})
describe('resolveInitialMode', () => {
const keys = ['tomt', 'mall', 'assistent'] as const
it('returns the persisted mode when valid', () => {
const uiState = { create_mode: { bookkeeping: 'mall' } }
expect(resolveInitialMode(uiState, 'bookkeeping', keys, 'tomt')).toBe('mall')
})
it('falls back when the persisted mode is stale', () => {
const uiState = { create_mode: { bookkeeping: 'removed-mode' } }
expect(resolveInitialMode(uiState, 'bookkeeping', keys, 'tomt')).toBe('tomt')
})
it('falls back when nothing is persisted', () => {
expect(resolveInitialMode(undefined, 'bookkeeping', keys, 'tomt')).toBe('tomt')
expect(resolveInitialMode({}, 'other', keys, 'assistent')).toBe('assistent')
})
})
+44
View File
@@ -0,0 +1,44 @@
'use client'
import type { UserUiState } from '@/types'
/**
* Fire-and-forget persistence of a partial user_preferences.ui_state patch
* (nav collapse/folds, split-button last-used modes). Cosmetic preference
* data: a lost write self-corrects on the next change, so failures are
* swallowed deliberately.
*/
export function persistUiState(patch: Partial<UserUiState>): void {
void fetch('/api/user/ui-state', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
}).catch(() => {})
}
/**
* Remember the last-used mode of a split button (ui_state.create_mode),
* keyed per surface (e.g. 'bookkeeping' -> 'mall').
*/
export function rememberCreateMode(surface: string, mode: string): void {
persistUiState({ create_mode: { [surface]: mode } })
}
/**
* Resolve which split-button mode to show as primary on first render:
* the persisted last-used mode when it's still one of the valid options,
* otherwise the given fallback. Guards against stale persisted keys after
* an option is renamed or removed.
*/
export function resolveInitialMode<T extends string>(
uiState: UserUiState | undefined | null,
surface: string,
validKeys: readonly T[],
fallback: T,
): T {
const persisted = uiState?.create_mode?.[surface]
if (persisted && (validKeys as readonly string[]).includes(persisted)) {
return persisted as T
}
return fallback
}
+2 -1
View File
@@ -63,7 +63,8 @@
"sent": "Sent",
"matched": "Matched",
"unmatched": "Unmatched"
}
},
"more_options": "More options"
},
"nav": {
"dashboard": "Overview",
+2 -1
View File
@@ -63,7 +63,8 @@
"sent": "Skickad",
"matched": "Matchad",
"unmatched": "Omatchad"
}
},
"more_options": "Fler alternativ"
},
"nav": {
"dashboard": "Översikt",