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>
204 lines
6.0 KiB
TypeScript
204 lines
6.0 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useRef, useEffect, useMemo, useCallback } from 'react'
|
|
import { useTranslations } from 'next-intl'
|
|
import { Input } from '@/components/ui/input'
|
|
|
|
export interface KommunRate {
|
|
kommun: string
|
|
totalRate: number
|
|
tableNumber: number
|
|
}
|
|
|
|
interface MunicipalityComboboxProps {
|
|
value: string
|
|
/** Fired when a municipality is committed from the list (with its derived table). */
|
|
onSelect: (kommun: string, tableNumber: number, totalRate: number) => void
|
|
/** Fired on free-text edits that don't match a known municipality. */
|
|
onChange?: (kommun: string) => void
|
|
/** Income year: drives which year's municipal rates are fetched. */
|
|
year: number
|
|
disabled?: boolean
|
|
id?: string
|
|
className?: string
|
|
}
|
|
|
|
const MAX_RESULTS = 50
|
|
|
|
/**
|
|
* Searchable folkbokföringskommun picker. Loads the kommun → skattetabell map
|
|
* from /api/salary/tax-tables/kommuner once, so picking a town auto-fills the
|
|
* tax table. Degrades to a plain free-text field if the list can't be fetched.
|
|
*/
|
|
export default function MunicipalityCombobox({
|
|
value,
|
|
onSelect,
|
|
onChange,
|
|
year,
|
|
disabled,
|
|
id,
|
|
className,
|
|
}: MunicipalityComboboxProps) {
|
|
const t = useTranslations('salary_employee')
|
|
const [search, setSearch] = useState(value)
|
|
const [isOpen, setIsOpen] = useState(false)
|
|
const [highlightedIndex, setHighlightedIndex] = useState(0)
|
|
const [kommuner, setKommuner] = useState<KommunRate[]>([])
|
|
const [loadFailed, setLoadFailed] = useState(false)
|
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
const listRef = useRef<HTMLDivElement>(null)
|
|
|
|
// Sync external value changes into the search field
|
|
useEffect(() => {
|
|
setSearch(value)
|
|
}, [value])
|
|
|
|
// Load the municipality list once per year
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
async function load() {
|
|
try {
|
|
const res = await fetch(`/api/salary/tax-tables/kommuner?year=${year}`)
|
|
if (!res.ok) throw new Error(`status ${res.status}`)
|
|
const { data } = await res.json()
|
|
if (!cancelled) setKommuner(data.kommuner ?? [])
|
|
} catch {
|
|
if (!cancelled) setLoadFailed(true)
|
|
}
|
|
}
|
|
load()
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [year])
|
|
|
|
const filtered = useMemo(() => {
|
|
const trimmed = search.trim().toLowerCase()
|
|
if (!trimmed) return kommuner.slice(0, MAX_RESULTS)
|
|
return kommuner.filter((k) => k.kommun.toLowerCase().includes(trimmed)).slice(0, MAX_RESULTS)
|
|
}, [kommuner, search])
|
|
|
|
useEffect(() => {
|
|
setHighlightedIndex(0)
|
|
}, [filtered])
|
|
|
|
useEffect(() => {
|
|
if (!isOpen || !listRef.current) return
|
|
const el = listRef.current.querySelector('[data-highlighted="true"]')
|
|
if (el) el.scrollIntoView({ block: 'nearest' })
|
|
}, [highlightedIndex, isOpen])
|
|
|
|
useEffect(() => {
|
|
function handleClickOutside(e: MouseEvent | TouchEvent) {
|
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
|
setIsOpen(false)
|
|
}
|
|
}
|
|
document.addEventListener('mousedown', handleClickOutside)
|
|
document.addEventListener('touchstart', handleClickOutside)
|
|
return () => {
|
|
document.removeEventListener('mousedown', handleClickOutside)
|
|
document.removeEventListener('touchstart', handleClickOutside)
|
|
}
|
|
}, [])
|
|
|
|
const select = useCallback(
|
|
(k: KommunRate) => {
|
|
setSearch(k.kommun)
|
|
setIsOpen(false)
|
|
onSelect(k.kommun, k.tableNumber, k.totalRate)
|
|
},
|
|
[onSelect]
|
|
)
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
if (!isOpen) {
|
|
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
setIsOpen(true)
|
|
e.preventDefault()
|
|
}
|
|
return
|
|
}
|
|
switch (e.key) {
|
|
case 'ArrowDown':
|
|
e.preventDefault()
|
|
setHighlightedIndex((p) => Math.min(p + 1, filtered.length - 1))
|
|
break
|
|
case 'ArrowUp':
|
|
e.preventDefault()
|
|
setHighlightedIndex((p) => Math.max(p - 1, 0))
|
|
break
|
|
case 'Enter':
|
|
if (filtered[highlightedIndex]) {
|
|
e.preventDefault()
|
|
select(filtered[highlightedIndex])
|
|
}
|
|
break
|
|
case 'Escape':
|
|
e.preventDefault()
|
|
setIsOpen(false)
|
|
break
|
|
}
|
|
}
|
|
|
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const next = e.target.value
|
|
setSearch(next)
|
|
onChange?.(next)
|
|
if (!isOpen) setIsOpen(true)
|
|
}
|
|
|
|
return (
|
|
<div ref={containerRef} className="relative">
|
|
<Input
|
|
id={id}
|
|
value={search}
|
|
onChange={handleInputChange}
|
|
onFocus={() => setIsOpen(true)}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder={t('kommun_search_placeholder')}
|
|
autoComplete="off"
|
|
disabled={disabled}
|
|
className={className}
|
|
/>
|
|
|
|
{isOpen && filtered.length > 0 && (
|
|
<div
|
|
ref={listRef}
|
|
className="absolute z-50 top-full left-0 mt-1 w-full max-h-[300px] overflow-y-auto rounded-lg border border-input bg-card shadow-md"
|
|
>
|
|
{filtered.map((k, i) => {
|
|
const isHighlighted = i === highlightedIndex
|
|
return (
|
|
<button
|
|
key={k.kommun}
|
|
type="button"
|
|
data-highlighted={isHighlighted}
|
|
className={`w-full text-left px-3 py-1.5 text-sm cursor-pointer flex items-baseline justify-between gap-2 ${
|
|
isHighlighted ? 'bg-primary/10 text-primary' : 'hover:bg-muted/50'
|
|
}`}
|
|
onMouseDown={(e) => {
|
|
e.preventDefault()
|
|
select(k)
|
|
}}
|
|
onMouseEnter={() => setHighlightedIndex(i)}
|
|
>
|
|
<span className="min-w-0 break-words">{k.kommun}</span>
|
|
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">
|
|
{t('kommun_table_number', { number: k.tableNumber })}
|
|
</span>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{loadFailed && (
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
{t('kommun_load_failed')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|