fix(ui): kill horizontal overflow in dialogs and cut the worst modal copy (#1732)
* fix(dialogs): kill horizontal overflow in dialogs and cut the worst modal copy Overflow hardening: - DialogTitle/DialogDescription and SheetTitle/SheetDescription get break-words at the primitive, so long unbroken interpolated strings (emails, product names, org numbers) can no longer widen any dialog. - AccountCombobox's non-flat dropdown is portaled to document.body with viewport-clamped geometry (new pure helper account-combobox-position.ts, unit-tested), the same fix info-tooltip.tsx applies to TooltipContent: the 34rem panel inside a scrollable DialogContent was the root cause of sideways-scrolling dialogs. Outside-click checks the portaled node, position tracks scroll/resize (capture phase), wheel/touchmove stop at the panel so react-remove-scroll's modal lock cannot block its scrolling, and DialogContent/SheetContent treat data-dialog-companion nodes as inside interactions so clicking the panel never dismisses the dialog. The flat variant is unchanged. - StrikeLinesDialog/CorrectionEntryDialog line rows switch bare 1fr grid tracks to minmax(0,1fr) and wrap the sm:contents-promoted AccountCombobox in a min-w-0 cell (SendInvoiceDialog's pattern). - New dialog-overflow-risk ratchet in no-new-antipatterns.mjs: bare fr tracks in dialog hosts, whitespace-nowrap inside DialogContent regions outside an allowlist, and unportaled >=20rem overlays; baselined at the post-fix 7 files. Copy reduction (convention 7, MatchVoucherDialog precedent): - New shared RattelseExplainer (HelpPopover) carries the "a posted verifikat cannot be edited directly" framing once; CorrectionEntryDialog, StrikeLinesDialog, RecordateEntryDialog and CorrectMetadataDialog drop their permanent inline explainer boxes and keep at most one sentence inline (hardcoded Swedish: verifikat surface). - SendInvoiceDialog keeps the actual addresses inline and moves the fixed CC/BCC framing plus the extra-address rules behind a HelpPopover (recipient_additional_hint replaced by recipient_help_fixed and recipient_help_additional in both messages files). - HelpPopover panels gain pointer-events-auto and the companion marker so they are actually interactive inside modal dialogs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bookkeeping): mechanism-accurate rattelse copy and calmer dropdown repositioning The shared RattelseExplainer claimed every rattelse is logged with who/when in the verifikat's rattelsehistorik, which is only true for the inline strike-and-replace track (StrikeLinesDialog, CorrectMetadataDialog). The storno dialogs (CorrectionEntryDialog, RecordateEntryDialog) never write that log: their BFL 5 kap 5 trail is the storno chain. The shared component now keeps only the universally true framing sentence, and each dialog's popover carries the trail sentence matching its own mechanism. AccountCombobox's capture-phase scroll/resize handler now skips setState when the recomputed position is shallow-equal to the current one (isSameDropdownPosition in the pure position helper, unit-tested) and ignores scroll events originating inside the portaled panel itself, so scrolling the account list no longer churns re-renders. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
68ca152127
commit
834cc4d0e8
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect, useMemo, useCallback, useId } from 'react'
|
||||
import { useState, useRef, useEffect, useLayoutEffect, useMemo, useCallback, useId } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -11,8 +12,19 @@ import {
|
||||
type SearchableAccount,
|
||||
type AccountSearchItem,
|
||||
} from '@/lib/bookkeeping/account-search'
|
||||
import {
|
||||
computeDropdownPosition,
|
||||
isSameDropdownPosition,
|
||||
type DropdownPosition,
|
||||
} from '@/components/bookkeeping/account-combobox-position'
|
||||
import type { BASAccount } from '@/types'
|
||||
|
||||
// Shared by every portaled panel instance: only stops propagation so the
|
||||
// browser's default scrolling still runs on the panel itself.
|
||||
function stopScrollPropagation(e: Event) {
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
interface AccountComboboxProps {
|
||||
value: string
|
||||
accounts: BASAccount[]
|
||||
@@ -59,7 +71,11 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(0)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const internalInputRef = useRef<HTMLInputElement>(null)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
const listRef = useRef<HTMLDivElement | null>(null)
|
||||
// The portaled (non-flat) dropdown panel. It is not a DOM descendant of
|
||||
// containerRef, so outside-click detection must check it separately.
|
||||
const portalPanelRef = useRef<HTMLDivElement | null>(null)
|
||||
const [dropdownPos, setDropdownPos] = useState<DropdownPosition | null>(null)
|
||||
const selectedNameId = useId()
|
||||
// Whether the user has typed or arrow-navigated since the field was focused.
|
||||
// Enter only selects the highlighted item after an actual interaction: a
|
||||
@@ -129,10 +145,74 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o
|
||||
}
|
||||
}, [highlightedIndex, isOpen])
|
||||
|
||||
// Keep the portaled (non-flat) dropdown glued to the trigger: measure off
|
||||
// containerRef when it opens, and re-measure while anything scrolls or the
|
||||
// window resizes underneath it. The capture-phase scroll listener also
|
||||
// catches scrolling ancestors such as DialogContent's overflow-y-auto body.
|
||||
const updateDropdownPosition = useCallback(() => {
|
||||
if (flat || !containerRef.current) return
|
||||
const rect = containerRef.current.getBoundingClientRect()
|
||||
const next = computeDropdownPosition(
|
||||
{ top: rect.top, bottom: rect.bottom, left: rect.left, width: rect.width },
|
||||
{ width: window.innerWidth, height: window.innerHeight },
|
||||
)
|
||||
// Bail out when nothing moved (e.g. scroll ticks that did not shift the
|
||||
// anchor): returning the previous reference lets React skip the re-render.
|
||||
setDropdownPos((prev) => (isSameDropdownPosition(prev, next) ? prev : next))
|
||||
}, [flat])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isOpen || flat) return
|
||||
updateDropdownPosition()
|
||||
const handleScroll = (e: Event) => {
|
||||
// Scrolling the portaled panel's own list never moves the anchor (the
|
||||
// panel is position: fixed): repositioning on it would just churn state
|
||||
// while the user scrolls the account list.
|
||||
if (e.target instanceof Node && portalPanelRef.current?.contains(e.target)) return
|
||||
updateDropdownPosition()
|
||||
}
|
||||
window.addEventListener('scroll', handleScroll, true)
|
||||
window.addEventListener('resize', updateDropdownPosition)
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleScroll, true)
|
||||
window.removeEventListener('resize', updateDropdownPosition)
|
||||
}
|
||||
}, [isOpen, flat, updateDropdownPosition])
|
||||
|
||||
// react-remove-scroll (active inside every modal dialog) preventDefaults
|
||||
// wheel/touchmove events that reach document from outside the dialog's DOM
|
||||
// tree, and the portaled panel lives outside that tree. Stopping the events
|
||||
// at the panel lets the browser scroll it natively; overscroll-contain on
|
||||
// the panel stops chained page scrolling at the list's edges.
|
||||
const attachPortalPanel = useCallback((el: HTMLDivElement | null) => {
|
||||
const prev = portalPanelRef.current
|
||||
if (prev) {
|
||||
prev.removeEventListener('wheel', stopScrollPropagation)
|
||||
prev.removeEventListener('touchmove', stopScrollPropagation)
|
||||
}
|
||||
portalPanelRef.current = el
|
||||
if (el) {
|
||||
el.addEventListener('wheel', stopScrollPropagation)
|
||||
el.addEventListener('touchmove', stopScrollPropagation)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const attachPortalListPanel = useCallback((el: HTMLDivElement | null) => {
|
||||
listRef.current = el
|
||||
attachPortalPanel(el)
|
||||
}, [attachPortalPanel])
|
||||
|
||||
// Close dropdown when clicking/tapping outside
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent | TouchEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
const target = e.target as Node
|
||||
// The non-flat dropdown is portaled to document.body, so it is not a
|
||||
// DOM descendant of containerRef: check the portaled panel too.
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(target) &&
|
||||
!(portalPanelRef.current && portalPanelRef.current.contains(target))
|
||||
) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
@@ -260,9 +340,96 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o
|
||||
|
||||
const showSelectedName = Boolean(selectedName && value && search === value)
|
||||
|
||||
// In flat mode the dropdown follows the trigger's width instead of forcing
|
||||
// 34rem: inside a SettingsRow that fixed width would overflow the dialog.
|
||||
const listWidthClass = flat ? 'w-full min-w-0' : 'min-w-[24rem] w-[max(100%,34rem)]'
|
||||
// Flat mode keeps the dropdown as an absolute child that follows the
|
||||
// trigger's width: inside a SettingsRow a fixed width would overflow the
|
||||
// dialog. The non-flat dropdown is portaled to document.body with an
|
||||
// explicit viewport-clamped geometry (computeDropdownPosition) instead, so
|
||||
// a scrollable DialogContent can never clip it or grow a horizontal
|
||||
// scrollbar around it (the fix info-tooltip.tsx already applies to
|
||||
// TooltipContent, extended to this dropdown).
|
||||
const flatListWidthClass = 'w-full min-w-0'
|
||||
|
||||
const portalPanelStyle: React.CSSProperties | undefined = dropdownPos
|
||||
? {
|
||||
left: dropdownPos.left,
|
||||
width: dropdownPos.width,
|
||||
maxHeight: dropdownPos.maxHeight,
|
||||
...(dropdownPos.top !== undefined
|
||||
? { top: dropdownPos.top }
|
||||
: { bottom: dropdownPos.bottom }),
|
||||
}
|
||||
: undefined
|
||||
|
||||
// data-dialog-companion: DialogContent/SheetContent treat a pointerdown
|
||||
// inside a node carrying this attribute as an inside interaction, so
|
||||
// clicking the portaled panel never dismisses the dialog hosting it.
|
||||
const listPanelContent = groupedAccounts.map((group) => (
|
||||
<div key={group.className}>
|
||||
<div className="sticky top-0 px-2 py-1.5 text-xs font-semibold text-muted-foreground bg-muted border-b border-input">
|
||||
{group.className}
|
||||
</div>
|
||||
{group.accounts.map((item) => {
|
||||
const flatIndex = flatList.indexOf(item)
|
||||
const isHighlighted = flatIndex === highlightedIndex
|
||||
return (
|
||||
<button
|
||||
key={item.account_number}
|
||||
type="button"
|
||||
data-highlighted={isHighlighted}
|
||||
className={`w-full text-left px-2 py-1.5 text-sm cursor-pointer flex items-baseline gap-2 ${
|
||||
isHighlighted ? 'bg-primary/10 text-primary' : 'hover:bg-muted/50'
|
||||
}`}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
selectAccount(item.account_number)
|
||||
}}
|
||||
onMouseEnter={() => setHighlightedIndex(flatIndex)}
|
||||
>
|
||||
<span className={`font-mono shrink-0 ${item.isActive ? '' : 'text-muted-foreground'}`}>
|
||||
{item.account_number}
|
||||
</span>
|
||||
<span className="flex-1 min-w-0 break-words">{item.account_name}</span>
|
||||
{!item.isActive && (
|
||||
<span className="shrink-0 self-center text-[11px] text-muted-foreground whitespace-nowrap">
|
||||
{notActivatedLabel}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
|
||||
const emptyPanelContent = (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Hittade inget konto som matchar.
|
||||
</p>
|
||||
{/^\d{4}$/.test(search.trim()) ? (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Om det är ett giltigt BAS-konto aktiveras det när du bokför.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Kontot kan behöva aktiveras i din kontoplan.
|
||||
</p>
|
||||
)}
|
||||
{onCreateAccount && (
|
||||
<button
|
||||
type="button"
|
||||
className="mt-2 flex w-full items-center gap-2 rounded-sm border border-input bg-card px-2 py-1.5 text-left text-sm hover:bg-muted/50"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
setIsOpen(false)
|
||||
onCreateAccount(search.trim())
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">Skapa konto "{search.trim()}"</span>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
const triggerProps = {
|
||||
ref: setInputRef,
|
||||
@@ -297,89 +464,55 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o
|
||||
) : null}
|
||||
|
||||
{/* Dropdown */}
|
||||
{isOpen && !disabled && flatList.length > 0 && (
|
||||
{isOpen && !disabled && flatList.length > 0 && (flat ? (
|
||||
<div
|
||||
ref={listRef}
|
||||
className={cn(
|
||||
'absolute z-50 top-full left-0 mt-1 max-h-[300px] overflow-y-auto rounded-lg border border-input bg-card shadow-md',
|
||||
listWidthClass,
|
||||
flatListWidthClass,
|
||||
)}
|
||||
>
|
||||
{groupedAccounts.map((group) => (
|
||||
<div key={group.className}>
|
||||
<div className="sticky top-0 px-2 py-1.5 text-xs font-semibold text-muted-foreground bg-muted border-b border-input">
|
||||
{group.className}
|
||||
</div>
|
||||
{group.accounts.map((item) => {
|
||||
const flatIndex = flatList.indexOf(item)
|
||||
const isHighlighted = flatIndex === highlightedIndex
|
||||
return (
|
||||
<button
|
||||
key={item.account_number}
|
||||
type="button"
|
||||
data-highlighted={isHighlighted}
|
||||
className={`w-full text-left px-2 py-1.5 text-sm cursor-pointer flex items-baseline gap-2 ${
|
||||
isHighlighted ? 'bg-primary/10 text-primary' : 'hover:bg-muted/50'
|
||||
}`}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
selectAccount(item.account_number)
|
||||
}}
|
||||
onMouseEnter={() => setHighlightedIndex(flatIndex)}
|
||||
>
|
||||
<span className={`font-mono shrink-0 ${item.isActive ? '' : 'text-muted-foreground'}`}>
|
||||
{item.account_number}
|
||||
</span>
|
||||
<span className="flex-1 min-w-0 break-words">{item.account_name}</span>
|
||||
{!item.isActive && (
|
||||
<span className="shrink-0 self-center text-[11px] text-muted-foreground whitespace-nowrap">
|
||||
{notActivatedLabel}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
{listPanelContent}
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
dropdownPos &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={attachPortalListPanel}
|
||||
data-dialog-companion=""
|
||||
className="fixed z-50 overflow-y-auto overscroll-contain pointer-events-auto rounded-lg border border-input bg-card shadow-md"
|
||||
style={portalPanelStyle}
|
||||
>
|
||||
{listPanelContent}
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
))}
|
||||
|
||||
{/* Empty state */}
|
||||
{isOpen && !disabled && search.trim() && flatList.length === 0 && (
|
||||
{isOpen && !disabled && search.trim() && flatList.length === 0 && (flat ? (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute z-50 top-full left-0 mt-1 rounded-lg border border-input bg-card shadow-md p-3',
|
||||
listWidthClass,
|
||||
flatListWidthClass,
|
||||
)}
|
||||
>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Hittade inget konto som matchar.
|
||||
</p>
|
||||
{/^\d{4}$/.test(search.trim()) ? (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Om det är ett giltigt BAS-konto aktiveras det när du bokför.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Kontot kan behöva aktiveras i din kontoplan.
|
||||
</p>
|
||||
)}
|
||||
{onCreateAccount && (
|
||||
<button
|
||||
type="button"
|
||||
className="mt-2 flex w-full items-center gap-2 rounded-sm border border-input bg-card px-2 py-1.5 text-left text-sm hover:bg-muted/50"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
setIsOpen(false)
|
||||
onCreateAccount(search.trim())
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">Skapa konto "{search.trim()}"</span>
|
||||
</button>
|
||||
)}
|
||||
{emptyPanelContent}
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
dropdownPos &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={attachPortalPanel}
|
||||
data-dialog-companion=""
|
||||
className="fixed z-50 overflow-y-auto overscroll-contain pointer-events-auto rounded-lg border border-input bg-card p-3 shadow-md"
|
||||
style={portalPanelStyle}
|
||||
>
|
||||
{emptyPanelContent}
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,9 +6,11 @@ import {
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import RattelseExplainer from '@/components/bookkeeping/RattelseExplainer'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
@@ -88,19 +90,31 @@ export default function CorrectMetadataDialog({ entry, open, onOpenChange, onCor
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Ändra text eller datum</DialogTitle>
|
||||
{/* Convention 7: the how-it-works copy lives behind the "?", not in
|
||||
the dialog flow. */}
|
||||
<div className="flex items-center gap-2">
|
||||
<DialogTitle>Ändra text eller datum</DialogTitle>
|
||||
<RattelseExplainer>
|
||||
<p>
|
||||
Verifikationstexten och datumet kan rättas här utan
|
||||
ändringsverifikation.
|
||||
</p>
|
||||
<p>
|
||||
Varje rättelse loggas med vem och när, och det ursprungliga
|
||||
innehållet förblir synligt i verifikatets rättelsehistorik.
|
||||
</p>
|
||||
<p>
|
||||
Om månaden redan är momsdeklarerad kan en datumflytt påverka
|
||||
den inlämnade deklarationen.
|
||||
</p>
|
||||
</RattelseExplainer>
|
||||
</div>
|
||||
<DialogDescription>
|
||||
Datumet kan bara flyttas inom samma bokföringsperiod: använd
|
||||
"Flytta till annat datum" för att byta period.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="rounded-lg bg-muted/50 border p-3 text-sm text-muted-foreground">
|
||||
<p>
|
||||
Verifikationstexten och datumet kan rättas utan ändringsverifikation. Rättelsen loggas
|
||||
med vem och när, och det gamla värdet förblir synligt i rättelsehistoriken. Datumet kan
|
||||
bara flyttas inom samma bokföringsperiod: använd "Flytta till annat datum" för att
|
||||
byta period. Om månaden redan är momsdeklarerad kan en datumflytt påverka den inlämnade
|
||||
deklarationen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="rattelse-description">Verifikationstext</Label>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
|
||||
import RattelseExplainer from '@/components/bookkeeping/RattelseExplainer'
|
||||
import { AddAccountDialog } from '@/components/bookkeeping/AddAccountDialog'
|
||||
import CorrectionPreview from '@/components/bookkeeping/CorrectionPreview'
|
||||
import {
|
||||
@@ -264,22 +265,31 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-3xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Skapa ändringsverifikation</DialogTitle>
|
||||
{/* Convention 7: the how-it-works copy lives behind the "?", not in
|
||||
the dialog flow. */}
|
||||
<div className="flex items-center gap-2">
|
||||
<DialogTitle>Skapa ändringsverifikation</DialogTitle>
|
||||
<RattelseExplainer>
|
||||
<p>
|
||||
Här skapas automatiskt en stornoverifikation som nollställer
|
||||
originalet och en ny verifikation med dina rättade uppgifter.
|
||||
Rättelsen bokförs i samma räkenskapsperiod som originalet: du
|
||||
hittar den under originalets räkenskapsår.
|
||||
</p>
|
||||
<p>
|
||||
Spårbarheten ligger i stornokedjan: originalet,
|
||||
stornoverifikationen och ändringsverifikationen förblir synliga
|
||||
i bokföringen och länkade till varandra.
|
||||
</p>
|
||||
<p>
|
||||
Tar du bort ett konto ur de rättade raderna nollställs det
|
||||
(stornon återför det). Vill du bara återföra hela verifikatet
|
||||
utan att ersätta det, använd Återför (storno) istället.
|
||||
</p>
|
||||
</RattelseExplainer>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Storno explanation */}
|
||||
<div className="rounded-lg bg-muted/50 border p-3 text-sm text-muted-foreground">
|
||||
<p className="font-medium text-foreground mb-1">Hur fungerar en ändringsverifikation?</p>
|
||||
<p>En bokförd verifikation kan inte ändras direkt. Istället skapas automatiskt:</p>
|
||||
<ol className="list-decimal list-inside mt-1 space-y-0.5">
|
||||
<li>En <strong>stornoverifikation</strong> som nollställer den ursprungliga</li>
|
||||
<li>En ny verifikation med dina rättade uppgifter</li>
|
||||
</ol>
|
||||
<p className="mt-2">
|
||||
Rättelsen bokförs i samma räkenskapsperiod som originalet: du hittar den under originalets räkenskapsår.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Original entry metadata: lines live inside CorrectionPreview below */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground flex-wrap">
|
||||
@@ -318,9 +328,7 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">Rättade rader</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Det här är hela den nya verifikationen: alla konton som ska finnas kvar måste stå
|
||||
kvar. Tar du bort ett konto nollställs det (stornon återför det). Vill du bara återföra
|
||||
hela verifikatet utan att ersätta det, använd Återför (storno) istället.
|
||||
Det här är hela den nya verifikationen: alla konton som ska finnas kvar måste stå kvar.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -340,19 +348,25 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor
|
||||
|
||||
<div className="space-y-2">
|
||||
{lines.map((line, index) => (
|
||||
<div key={index} className="space-y-2 sm:space-y-0 sm:grid sm:grid-cols-[1fr_1fr_120px_120px_auto] sm:gap-2 sm:items-start border-b sm:border-0 pb-3 sm:pb-0 last:border-0">
|
||||
<div className="grid grid-cols-[1fr_auto] sm:contents gap-2">
|
||||
<AccountCombobox
|
||||
value={line.account_number}
|
||||
accounts={activeAccounts}
|
||||
catalog={selectableCatalog}
|
||||
onChange={(v) => updateLineAccount(index, v)}
|
||||
onCreateAccount={(prefill) => {
|
||||
setCreatingAccountForLine(index)
|
||||
setCreateAccountPrefill(prefill)
|
||||
}}
|
||||
disabled={accountsStatus !== 'ready'}
|
||||
/>
|
||||
<div key={index} className="space-y-2 sm:space-y-0 sm:grid sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_120px_120px_auto] sm:gap-2 sm:items-start border-b sm:border-0 pb-3 sm:pb-0 last:border-0">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] sm:contents gap-2">
|
||||
{/* min-w-0: at sm: the sm:contents wrapper promotes this cell
|
||||
to a direct grid item; without it the combobox refuses to
|
||||
shrink below its content and overflows the dialog (same
|
||||
pattern as SendInvoiceDialog's desktop rows). */}
|
||||
<div className="min-w-0">
|
||||
<AccountCombobox
|
||||
value={line.account_number}
|
||||
accounts={activeAccounts}
|
||||
catalog={selectableCatalog}
|
||||
onChange={(v) => updateLineAccount(index, v)}
|
||||
onCreateAccount={(prefill) => {
|
||||
setCreatingAccountForLine(index)
|
||||
setCreateAccountPrefill(prefill)
|
||||
}}
|
||||
disabled={accountsStatus !== 'ready'}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
'use client'
|
||||
|
||||
import { HelpPopover } from '@/components/ui/help-popover'
|
||||
|
||||
interface RattelseExplainerProps {
|
||||
/** Per-dialog specifics, rendered after the shared framing paragraph. */
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared "?" help for the rättelse family of dialogs (CorrectionEntryDialog,
|
||||
* StrikeLinesDialog, RecordateEntryDialog, CorrectMetadataDialog): one place
|
||||
* for the "a posted verifikat cannot be edited directly" framing, so the four
|
||||
* dialogs stop maintaining near-duplicate inline paragraphs. Rendered next to
|
||||
* the DialogTitle per UI-migration convention 7 (help lives behind a "?",
|
||||
* not in the dialog flow: see MatchVoucherDialog). Stays Swedish
|
||||
* (verifikat surface, .claude/rules/i18n.md).
|
||||
*
|
||||
* Only the universally true framing lives here. The audit-trail sentence is
|
||||
* mechanism-specific (BFL 5 kap 5 §: two distinct correction tracks) and must
|
||||
* come from each dialog's children: the inline strike-and-replace dialogs
|
||||
* (StrikeLinesDialog, CorrectMetadataDialog) write the who/when
|
||||
* rättelsehistorik log, while the storno dialogs (CorrectionEntryDialog,
|
||||
* RecordateEntryDialog) never touch that log: their trail is the storno chain
|
||||
* of linked verifikat. Claiming the rättelsehistorik here would be false for
|
||||
* the storno paths.
|
||||
*/
|
||||
export default function RattelseExplainer({ children, className }: RattelseExplainerProps) {
|
||||
return (
|
||||
<HelpPopover className={className}>
|
||||
<p>
|
||||
En bokförd verifikation kan inte ändras direkt: enligt bokföringslagen
|
||||
måste varje rättelse vara spårbar i efterhand.
|
||||
</p>
|
||||
<div className="mt-2 space-y-2">{children}</div>
|
||||
</HelpPopover>
|
||||
)
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import RattelseExplainer from '@/components/bookkeeping/RattelseExplainer'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { AlertTriangle, Lock, ArrowRight } from 'lucide-react'
|
||||
@@ -160,22 +161,25 @@ export default function RecordateEntryDialog({ entry, open, onOpenChange, onMove
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rätta datum</DialogTitle>
|
||||
{/* Convention 7: the how-it-works copy lives behind the "?", not in
|
||||
the dialog flow. */}
|
||||
<div className="flex items-center gap-2">
|
||||
<DialogTitle>Rätta datum</DialogTitle>
|
||||
<RattelseExplainer>
|
||||
<p>
|
||||
Raderna behålls oförändrade: en stornoverifikation nollställer
|
||||
originalet i sin period, och en ny verifikation bokförs med
|
||||
samma rader på det nya datumet.
|
||||
</p>
|
||||
<p>
|
||||
Spårbarheten ligger i stornokedjan: originalet,
|
||||
stornoverifikationen och den nya verifikationen förblir synliga
|
||||
i bokföringen och länkade till varandra.
|
||||
</p>
|
||||
</RattelseExplainer>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Explanation */}
|
||||
<div className="rounded-lg bg-muted/50 border p-3 text-sm text-muted-foreground">
|
||||
<p className="font-medium text-foreground mb-1">Flytta verifikationen till rätt datum</p>
|
||||
<p>
|
||||
En bokförd verifikation kan inte ändras direkt. Raderna behålls oförändrade: istället
|
||||
skapas automatiskt:
|
||||
</p>
|
||||
<ol className="list-decimal list-inside mt-1 space-y-0.5">
|
||||
<li>En <strong>stornoverifikation</strong> som nollställer originalet i sin period</li>
|
||||
<li>En ny verifikation med samma rader, bokförd på det nya datumet</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{/* Original */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground flex-wrap">
|
||||
|
||||
@@ -7,12 +7,14 @@ import {
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
|
||||
import RattelseExplainer from '@/components/bookkeeping/RattelseExplainer'
|
||||
import { AddAccountDialog } from '@/components/bookkeeping/AddAccountDialog'
|
||||
import { AccountNumber } from '@/components/ui/account-number'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
@@ -220,19 +222,31 @@ export default function StrikeLinesDialog({ entry, open, onOpenChange, onCorrect
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-3xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Stryk rader i verifikatet</DialogTitle>
|
||||
{/* Convention 7: the how-it-works copy lives behind the "?", not in
|
||||
the dialog flow. */}
|
||||
<div className="flex items-center gap-2">
|
||||
<DialogTitle>Stryk rader i verifikatet</DialogTitle>
|
||||
<RattelseExplainer>
|
||||
<p>
|
||||
Här stryks felaktiga rader och ersätts direkt i samma verifikat,
|
||||
utan ändringsverifikation. Det fungerar bara i öppna, olåsta
|
||||
perioder.
|
||||
</p>
|
||||
<p>
|
||||
Varje rättelse loggas med vem och när, och de ursprungliga
|
||||
raderna förblir synliga i verifikatets rättelsehistorik.
|
||||
</p>
|
||||
<p>
|
||||
Om månaden redan är momsdeklarerad kan en ändring av momskonton
|
||||
påverka den inlämnade deklarationen.
|
||||
</p>
|
||||
</RattelseExplainer>
|
||||
</div>
|
||||
<DialogDescription>
|
||||
De strukna raderna förblir synliga (överstrukna) i verifikatet.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="rounded-lg bg-muted/50 border p-3 text-sm text-muted-foreground">
|
||||
<p className="font-medium text-foreground mb-1">Rättelse i samma verifikat</p>
|
||||
<p>
|
||||
Felaktiga rader stryks och ersätts direkt i verifikatet, utan ändringsverifikation.
|
||||
De strukna raderna förblir synliga (överstrukna) och rättelsen loggas med vem och när,
|
||||
enligt bokföringslagen. Fungerar bara i öppna, olåsta perioder. Om månaden redan är
|
||||
momsdeklarerad kan en ändring av momskonton påverka den inlämnade deklarationen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Original lines with strike checkboxes */}
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">Markera rader som ska strykas</p>
|
||||
@@ -295,19 +309,25 @@ export default function StrikeLinesDialog({ entry, open, onOpenChange, onCorrect
|
||||
|
||||
<div className="space-y-2">
|
||||
{newLines.map((line, index) => (
|
||||
<div key={index} className="space-y-2 sm:space-y-0 sm:grid sm:grid-cols-[1fr_1fr_120px_120px_auto] sm:gap-2 sm:items-start border-b sm:border-0 pb-3 sm:pb-0 last:border-0">
|
||||
<div className="grid grid-cols-[1fr_auto] sm:contents gap-2">
|
||||
<AccountCombobox
|
||||
value={line.account_number}
|
||||
accounts={activeAccounts}
|
||||
catalog={selectableCatalog}
|
||||
onChange={(v) => updateNewLineAccount(index, v)}
|
||||
onCreateAccount={(prefill) => {
|
||||
setCreatingAccountForLine(index)
|
||||
setCreateAccountPrefill(prefill)
|
||||
}}
|
||||
disabled={accountsStatus !== 'ready'}
|
||||
/>
|
||||
<div key={index} className="space-y-2 sm:space-y-0 sm:grid sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_120px_120px_auto] sm:gap-2 sm:items-start border-b sm:border-0 pb-3 sm:pb-0 last:border-0">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] sm:contents gap-2">
|
||||
{/* min-w-0: at sm: the sm:contents wrapper promotes this cell
|
||||
to a direct grid item; without it the combobox refuses to
|
||||
shrink below its content and overflows the dialog (same
|
||||
pattern as SendInvoiceDialog's desktop rows). */}
|
||||
<div className="min-w-0">
|
||||
<AccountCombobox
|
||||
value={line.account_number}
|
||||
accounts={activeAccounts}
|
||||
catalog={selectableCatalog}
|
||||
onChange={(v) => updateNewLineAccount(index, v)}
|
||||
onCreateAccount={(prefill) => {
|
||||
setCreatingAccountForLine(index)
|
||||
setCreateAccountPrefill(prefill)
|
||||
}}
|
||||
disabled={accountsStatus !== 'ready'}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
computeDropdownPosition,
|
||||
isSameDropdownPosition,
|
||||
DROPDOWN_PREFERRED_WIDTH,
|
||||
DROPDOWN_VIEWPORT_MARGIN,
|
||||
DROPDOWN_ANCHOR_GAP,
|
||||
DROPDOWN_MAX_HEIGHT,
|
||||
} from '../account-combobox-position'
|
||||
|
||||
const desktop = { width: 1440, height: 900 }
|
||||
|
||||
describe('computeDropdownPosition', () => {
|
||||
it('opens below the anchor at the preferred width when there is room', () => {
|
||||
const pos = computeDropdownPosition(
|
||||
{ top: 200, bottom: 232, left: 300, width: 160 },
|
||||
desktop,
|
||||
)
|
||||
expect(pos.top).toBe(232 + DROPDOWN_ANCHOR_GAP)
|
||||
expect(pos.bottom).toBeUndefined()
|
||||
expect(pos.left).toBe(300)
|
||||
expect(pos.width).toBe(DROPDOWN_PREFERRED_WIDTH)
|
||||
expect(pos.maxHeight).toBe(DROPDOWN_MAX_HEIGHT)
|
||||
})
|
||||
|
||||
it('keeps the anchor width when the trigger is wider than the preferred width', () => {
|
||||
const pos = computeDropdownPosition(
|
||||
{ top: 200, bottom: 232, left: 100, width: 700 },
|
||||
desktop,
|
||||
)
|
||||
expect(pos.width).toBe(700)
|
||||
})
|
||||
|
||||
it('clamps left so the panel never crosses the right viewport edge', () => {
|
||||
const pos = computeDropdownPosition(
|
||||
{ top: 200, bottom: 232, left: 1200, width: 160 },
|
||||
desktop,
|
||||
)
|
||||
expect(pos.left + pos.width).toBe(desktop.width - DROPDOWN_VIEWPORT_MARGIN)
|
||||
})
|
||||
|
||||
it('never puts the panel past the left margin', () => {
|
||||
const pos = computeDropdownPosition(
|
||||
{ top: 200, bottom: 232, left: 2, width: 160 },
|
||||
desktop,
|
||||
)
|
||||
expect(pos.left).toBe(DROPDOWN_VIEWPORT_MARGIN)
|
||||
})
|
||||
|
||||
it('shrinks to the viewport on a narrow (mobile) screen', () => {
|
||||
const mobile = { width: 375, height: 700 }
|
||||
const pos = computeDropdownPosition(
|
||||
{ top: 100, bottom: 140, left: 16, width: 200 },
|
||||
mobile,
|
||||
)
|
||||
expect(pos.width).toBe(375 - DROPDOWN_VIEWPORT_MARGIN * 2)
|
||||
expect(pos.left).toBe(DROPDOWN_VIEWPORT_MARGIN)
|
||||
})
|
||||
|
||||
it('caps maxHeight to the space below the anchor', () => {
|
||||
const pos = computeDropdownPosition(
|
||||
{ top: 650, bottom: 682, left: 300, width: 160 },
|
||||
desktop,
|
||||
)
|
||||
expect(pos.top).toBe(682 + DROPDOWN_ANCHOR_GAP)
|
||||
expect(pos.maxHeight).toBe(
|
||||
desktop.height - 682 - DROPDOWN_ANCHOR_GAP - DROPDOWN_VIEWPORT_MARGIN,
|
||||
)
|
||||
})
|
||||
|
||||
it('flips above the anchor when the space below is too small', () => {
|
||||
const pos = computeDropdownPosition(
|
||||
{ top: 800, bottom: 832, left: 300, width: 160 },
|
||||
desktop,
|
||||
)
|
||||
expect(pos.top).toBeUndefined()
|
||||
expect(pos.bottom).toBe(desktop.height - 800 + DROPDOWN_ANCHOR_GAP)
|
||||
expect(pos.maxHeight).toBe(DROPDOWN_MAX_HEIGHT)
|
||||
})
|
||||
|
||||
it('stays below when the space above is even smaller than below', () => {
|
||||
const shortViewport = { width: 1440, height: 220 }
|
||||
const pos = computeDropdownPosition(
|
||||
{ top: 40, bottom: 72, left: 300, width: 160 },
|
||||
shortViewport,
|
||||
)
|
||||
expect(pos.top).toBe(72 + DROPDOWN_ANCHOR_GAP)
|
||||
// Cramped viewport: the height floor keeps the list usable and scrollable.
|
||||
expect(pos.maxHeight).toBeGreaterThanOrEqual(96)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isSameDropdownPosition', () => {
|
||||
const anchor = { top: 200, bottom: 232, left: 300, width: 160 }
|
||||
|
||||
it('is false against null (no previous position)', () => {
|
||||
expect(isSameDropdownPosition(null, computeDropdownPosition(anchor, desktop))).toBe(false)
|
||||
})
|
||||
|
||||
it('is true for two computations off an unmoved anchor', () => {
|
||||
const a = computeDropdownPosition(anchor, desktop)
|
||||
const b = computeDropdownPosition({ ...anchor }, desktop)
|
||||
expect(isSameDropdownPosition(a, b)).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['left', { left: 301 }],
|
||||
['width', { width: 545 }],
|
||||
['maxHeight', { maxHeight: 299 }],
|
||||
['top', { top: 237 }],
|
||||
] as const)('is false when %s differs', (_field, patch) => {
|
||||
const a = computeDropdownPosition(anchor, desktop)
|
||||
expect(isSameDropdownPosition(a, { ...a, ...patch })).toBe(false)
|
||||
})
|
||||
|
||||
it('distinguishes open-below from open-above at the same coordinates', () => {
|
||||
const below = computeDropdownPosition(anchor, desktop)
|
||||
const above = computeDropdownPosition({ top: 800, bottom: 832, left: 300, width: 160 }, desktop)
|
||||
expect(below.top).toBeDefined()
|
||||
expect(above.bottom).toBeDefined()
|
||||
expect(isSameDropdownPosition(below, above)).toBe(false)
|
||||
// Flipping sides toggles which of top/bottom is undefined: the guard must
|
||||
// compare both, not just the defined one.
|
||||
expect(
|
||||
isSameDropdownPosition(below, { ...below, top: undefined, bottom: 123 }),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Pure positioning for AccountCombobox's portaled (non-flat) dropdown.
|
||||
*
|
||||
* The non-flat dropdown renders on document.body (see AccountCombobox) so a
|
||||
* scrollable DialogContent can never clip it or grow a horizontal scrollbar
|
||||
* around it. That trades CSS anchoring (absolute + top-full) for explicit
|
||||
* viewport math, which lives here so it can be unit-tested headlessly.
|
||||
*
|
||||
* All numbers are CSS pixels in viewport coordinates (getBoundingClientRect
|
||||
* space), matching position: fixed.
|
||||
*/
|
||||
|
||||
export interface DropdownAnchorRect {
|
||||
top: number
|
||||
bottom: number
|
||||
left: number
|
||||
width: number
|
||||
}
|
||||
|
||||
export interface DropdownViewportSize {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export interface DropdownPosition {
|
||||
left: number
|
||||
width: number
|
||||
maxHeight: number
|
||||
/** Set when the dropdown opens below the anchor (CSS `top`). */
|
||||
top?: number
|
||||
/**
|
||||
* Set when the dropdown opens above the anchor (CSS `bottom`, measured from
|
||||
* the viewport bottom). Anchoring by `bottom` lets the panel grow upward
|
||||
* without knowing its own height.
|
||||
*/
|
||||
bottom?: number
|
||||
}
|
||||
|
||||
/** 34rem: wide enough for account number + name + activation marker. */
|
||||
export const DROPDOWN_PREFERRED_WIDTH = 544
|
||||
/** Minimum gap kept between the dropdown and every viewport edge. */
|
||||
export const DROPDOWN_VIEWPORT_MARGIN = 8
|
||||
/** Gap between the anchor (trigger) and the dropdown. */
|
||||
export const DROPDOWN_ANCHOR_GAP = 4
|
||||
/** The dropdown's own scroll ceiling (was max-h-[300px] pre-portal). */
|
||||
export const DROPDOWN_MAX_HEIGHT = 300
|
||||
/** Below this much room the dropdown flips above the anchor instead. */
|
||||
const MIN_USEFUL_HEIGHT = 120
|
||||
/** Height floor so a cramped viewport still shows a scrollable list. */
|
||||
const HEIGHT_FLOOR = 96
|
||||
|
||||
/**
|
||||
* Shallow equality over the full position shape. The scroll/resize reposition
|
||||
* handler uses this to skip setState when the recomputed geometry is
|
||||
* unchanged (e.g. scroll events that did not move the anchor), so React does
|
||||
* not re-render the open dropdown on every scroll tick.
|
||||
*/
|
||||
export function isSameDropdownPosition(
|
||||
a: DropdownPosition | null,
|
||||
b: DropdownPosition,
|
||||
): boolean {
|
||||
return (
|
||||
a !== null &&
|
||||
a.left === b.left &&
|
||||
a.width === b.width &&
|
||||
a.maxHeight === b.maxHeight &&
|
||||
a.top === b.top &&
|
||||
a.bottom === b.bottom
|
||||
)
|
||||
}
|
||||
|
||||
export function computeDropdownPosition(
|
||||
anchor: DropdownAnchorRect,
|
||||
viewport: DropdownViewportSize,
|
||||
): DropdownPosition {
|
||||
const margin = DROPDOWN_VIEWPORT_MARGIN
|
||||
const maxWidth = Math.max(viewport.width - margin * 2, 0)
|
||||
const width = Math.min(Math.max(anchor.width, DROPDOWN_PREFERRED_WIDTH), maxWidth)
|
||||
const left = Math.max(margin, Math.min(anchor.left, viewport.width - width - margin))
|
||||
|
||||
const spaceBelow = viewport.height - anchor.bottom - DROPDOWN_ANCHOR_GAP - margin
|
||||
const spaceAbove = anchor.top - DROPDOWN_ANCHOR_GAP - margin
|
||||
const openUp = spaceBelow < MIN_USEFUL_HEIGHT && spaceAbove > spaceBelow
|
||||
|
||||
const available = openUp ? spaceAbove : spaceBelow
|
||||
const maxHeight = Math.min(DROPDOWN_MAX_HEIGHT, Math.max(available, HEIGHT_FLOOR))
|
||||
|
||||
if (openUp) {
|
||||
return { left, width, maxHeight, bottom: viewport.height - anchor.top + DROPDOWN_ANCHOR_GAP }
|
||||
}
|
||||
return { left, width, maxHeight, top: anchor.bottom + DROPDOWN_ANCHOR_GAP }
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { HelpPopover } from '@/components/ui/help-popover'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
@@ -497,10 +498,21 @@ export default function SendInvoiceDialog({
|
||||
{mode === 'email' && (
|
||||
<div className="space-y-3 rounded-lg border border-border p-3">
|
||||
<div className="space-y-1 text-sm">
|
||||
<p>
|
||||
<span className="font-medium">{t('recipient_to_label')}:</span>{' '}
|
||||
{invoice.customer.email}
|
||||
</p>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p>
|
||||
<span className="font-medium">{t('recipient_to_label')}:</span>{' '}
|
||||
{invoice.customer.email}
|
||||
</p>
|
||||
{/* Convention 7: the why of fixed CC/BCC and the extra
|
||||
address rules live behind the "?": only the actual
|
||||
addresses stay inline. */}
|
||||
<HelpPopover>
|
||||
<p>{t('recipient_help_fixed')}</p>
|
||||
{canCustomizeRecipients && (
|
||||
<p className="mt-2">{t('recipient_help_additional')}</p>
|
||||
)}
|
||||
</HelpPopover>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
<span className="font-medium text-foreground">{t('recipient_fixed_cc_label')}:</span>{' '}
|
||||
{fixedRecipients.cc.length > 0 ? fixedRecipients.cc.join(', ') : t('recipient_none')}
|
||||
@@ -536,7 +548,6 @@ export default function SendInvoiceDialog({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('recipient_additional_hint')}</p>
|
||||
{recipientError && (
|
||||
<p className="text-sm text-destructive" role="alert">{recipientError}</p>
|
||||
)}
|
||||
|
||||
@@ -31,11 +31,23 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
>(({ className, children, onInteractOutside, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
// Companion overlays (AccountCombobox's dropdown, HelpPopover panels)
|
||||
// are portaled to document.body so this content's overflow-y-auto can
|
||||
// never clip them. DOM-wise that puts them OUTSIDE the dialog, so Radix
|
||||
// would otherwise dismiss the dialog on a pointerdown inside them:
|
||||
// anything marked data-dialog-companion counts as inside.
|
||||
onInteractOutside={(event) => {
|
||||
onInteractOutside?.(event)
|
||||
const target = event.target
|
||||
if (target instanceof Element && target.closest('[data-dialog-companion]')) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-[var(--shadow-md)] max-h-[calc(100dvh-2rem)] overflow-y-auto scrollbar-visible 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]:zoom-out-98 data-[state=open]:zoom-in-98 sm:rounded-xl",
|
||||
className
|
||||
@@ -106,7 +118,7 @@ const DialogTitle = React.forwardRef<
|
||||
ref={ref}
|
||||
data-ph-unmask=""
|
||||
className={cn(
|
||||
"text-lg leading-none tracking-tight",
|
||||
"break-words text-lg leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -121,7 +133,7 @@ const DialogDescription = React.forwardRef<
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
data-ph-unmask=""
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
className={cn("break-words text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
@@ -86,9 +86,13 @@ export function HelpPopover({ children, className }: HelpPopoverProps) {
|
||||
ref={panelRef}
|
||||
role="note"
|
||||
data-help-popover=""
|
||||
// Inside a modal dialog the panel is DOM-outside DialogContent:
|
||||
// data-dialog-companion keeps a click in it from dismissing the
|
||||
// dialog, and pointer-events-auto undoes the modal body lock.
|
||||
data-dialog-companion=""
|
||||
// data-ph-unmask: page help is static i18n chrome in session replays.
|
||||
data-ph-unmask=""
|
||||
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"
|
||||
className="pointer-events-auto 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}
|
||||
|
||||
+12
-3
@@ -49,11 +49,20 @@ interface SheetContentProps
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
SheetContentProps
|
||||
>(({ className, children, side = "right", ...props }, ref) => (
|
||||
>(({ className, children, side = "right", onInteractOutside, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
// Same companion-overlay guard as DialogContent: a portaled panel
|
||||
// marked data-dialog-companion counts as an inside interaction.
|
||||
onInteractOutside={(event) => {
|
||||
onInteractOutside?.(event)
|
||||
const target = event.target
|
||||
if (target instanceof Element && target.closest('[data-dialog-companion]')) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"fixed z-50 flex flex-col gap-4 bg-background p-6 transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-200 data-[state=open]:duration-300",
|
||||
sideClasses[side],
|
||||
@@ -105,7 +114,7 @@ const SheetTitle = React.forwardRef<
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
data-ph-unmask=""
|
||||
className={cn("text-base tracking-tight", className)}
|
||||
className={cn("break-words text-base tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
@@ -118,7 +127,7 @@ const SheetDescription = React.forwardRef<
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
data-ph-unmask=""
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
className={cn("break-words text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
Reference in New Issue
Block a user