Files
accounted/components/settings/BankNameCombobox.tsx
T
Jakob Wennberg 9686b54b41 refactor(design): lock the border-radius ladder, one radius per role (#1607)
Seven radii were in circulation (4/5/6/8/12/16px + pill) with no rule for
which went where; one toolbar row on /transactions mixed four shape
languages. This locks a 4-tier ladder (design.md convention 16):

- pill: interactive toolbar controls (buttons, chips, pickers, segmented
  controls, toolbar search, count nubs)
- rounded-xl (12px): overlay tier: page panel, dialogs, slide-overs
- rounded-lg (8px): cards, form fields, popover/menu content, boxes
- rounded-sm (4px): nested leaves (menu items, checkboxes, kbd/code nubs)

Changes:
- New SegmentedControl primitive (pill-in-pill tablist, h-8) replaces the
  hand-rolled bg-muted/70 tablist copied across 11 files
- New ToolbarSearch primitive (pill, h-8) adopted on 9 page toolbars;
  dialog/picker searches keep the rounded-lg Input
- dialog.tsx 8px -> 12px, matching SettingsModal/slide-over/CommandPalette
- ContextPicker chips at the shared h-8 toolbar height
- ~300 rounded-md / bare rounded call sites remapped by role; auth icon
  tiles and the mobile nav sheet come down from 16px to 12px
- rounded-md, bare rounded, rounded-2xl and rounded-[Npx] are dead
  vocabulary, enforced by a new off-ladder-radius check in check:guards

Verified: lint 0 errors, 14422 unit tests pass, check:guards green, tsc
clean on all changed files, sandbox screenshots of transactions/
bookkeeping/granskning toolbars and the Ny verifikation dialog.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 08:55:37 +02:00

202 lines
6.0 KiB
TypeScript

'use client'
import { useEffect, useRef, useState } from 'react'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
interface BankOption {
name: string
logo?: string
bic?: string
}
const FALLBACK_BANKS: BankOption[] = [
{ name: 'Nordea', bic: 'NDEASESS' },
{ name: 'SEB', bic: 'ESSESESS' },
{ name: 'Swedbank', bic: 'SWEDSESS' },
{ name: 'Handelsbanken', bic: 'HANDSESS' },
{ name: 'Danske Bank', bic: 'DABASES' },
{ name: 'Länsförsäkringar', bic: 'ELLFSESS' },
{ name: 'Skandiabanken', bic: 'SKIASESS' },
{ name: 'ICA Banken' },
{ name: 'Avanza Bank' },
{ name: 'Sparbanken' },
]
interface BankNameComboboxProps {
defaultValue?: string
value?: string
onChange?: (value: string) => void
enableBankingEnabled?: boolean
'aria-label'?: string
}
export function BankNameCombobox({ defaultValue = '', value: controlledValue, onChange, enableBankingEnabled = false, 'aria-label': ariaLabel }: BankNameComboboxProps) {
const isControlled = controlledValue !== undefined
const [internalValue, setInternalValue] = useState(defaultValue)
const value = isControlled ? controlledValue : internalValue
const setValue = (v: string) => {
if (!isControlled) setInternalValue(v)
onChange?.(v)
}
const [banks, setBanks] = useState<BankOption[]>(FALLBACK_BANKS)
const [isOpen, setIsOpen] = useState(false)
const [highlightedIndex, setHighlightedIndex] = useState(-1)
const containerRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const listRef = useRef<HTMLUListElement>(null)
useEffect(() => {
if (!enableBankingEnabled) return
let cancelled = false
async function fetchBanks() {
try {
const res = await fetch('/api/extensions/ext/enable-banking/banks')
if (!res.ok) return
const data = await res.json()
if (!cancelled && data.banks?.length > 0) {
setBanks(data.banks)
}
} catch {
// Keep fallback list
}
}
fetchBanks()
return () => { cancelled = true }
}, [enableBankingEnabled])
const filtered = value.trim()
? banks.filter((b) => b.name.toLowerCase().includes(value.toLowerCase()))
: banks
useEffect(() => {
setHighlightedIndex(-1)
}, [value])
useEffect(() => {
if (!isOpen) return
function handleClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [isOpen])
// Scroll highlighted item into view
useEffect(() => {
if (highlightedIndex < 0 || !listRef.current) return
const item = listRef.current.children[highlightedIndex] as HTMLElement
item?.scrollIntoView({ block: 'nearest' })
}, [highlightedIndex])
function selectBank(bank: BankOption) {
setValue(bank.name)
setIsOpen(false)
inputRef.current?.focus()
}
function handleKeyDown(e: React.KeyboardEvent) {
if (!isOpen && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
setIsOpen(true)
e.preventDefault()
return
}
if (!isOpen) return
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
setHighlightedIndex((i) => (i < filtered.length - 1 ? i + 1 : 0))
break
case 'ArrowUp':
e.preventDefault()
setHighlightedIndex((i) => (i > 0 ? i - 1 : filtered.length - 1))
break
case 'Enter':
e.preventDefault()
if (highlightedIndex >= 0 && filtered[highlightedIndex]) {
selectBank(filtered[highlightedIndex])
}
break
case 'Escape':
setIsOpen(false)
break
}
}
return (
<div ref={containerRef} className="relative">
<input type="hidden" name="bank_name" value={value} />
<Input
aria-label={ariaLabel}
ref={inputRef}
type="text"
placeholder="t.ex. Nordea"
maxLength={100}
value={value}
onChange={(e) => {
setValue(e.target.value)
if (!isOpen) setIsOpen(true)
}}
onFocus={() => setIsOpen(true)}
onKeyDown={handleKeyDown}
role="combobox"
aria-expanded={isOpen}
aria-autocomplete="list"
aria-controls="bank-name-listbox"
autoComplete="off"
/>
{isOpen && filtered.length > 0 && (
<ul
ref={listRef}
id="bank-name-listbox"
role="listbox"
className="absolute z-50 mt-1 max-h-56 w-full overflow-auto rounded-lg border border-border bg-popover shadow-md"
>
{filtered.map((bank, i) => (
<li
key={bank.name}
role="option"
aria-selected={highlightedIndex === i}
className={cn(
'flex items-center gap-2 px-3 py-2 text-sm cursor-pointer transition-colors',
highlightedIndex === i && 'bg-accent text-accent-foreground',
)}
onMouseEnter={() => setHighlightedIndex(i)}
onMouseDown={(e) => {
e.preventDefault() // prevent blur before click registers
selectBank(bank)
}}
>
{bank.logo ? (
<img
src={bank.logo}
alt=""
className="h-5 w-5 flex-shrink-0 rounded-sm object-contain"
/>
) : (
<svg
className="h-5 w-5 flex-shrink-0 text-muted-foreground"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 21h18M3 10h18M5 6l7-3 7 3M4 10v11M20 10v11M8 14v4M12 14v4M16 14v4" />
</svg>
)}
<span className="truncate">{bank.name}</span>
</li>
))}
</ul>
)}
</div>
)
}