Files
accounted/components/ui/tag-input.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

126 lines
3.5 KiB
TypeScript

'use client'
import * as React from 'react'
import { X } from 'lucide-react'
import { cn } from '@/lib/utils'
interface TagInputProps {
value?: string
onChange?: (value: string) => void
placeholder?: string
className?: string
disabled?: boolean
}
const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
({ value = '', onChange, placeholder, className, disabled }, ref) => {
const [inputValue, setInputValue] = React.useState('')
const inputRef = React.useRef<HTMLInputElement>(null)
React.useImperativeHandle(ref, () => inputRef.current!)
const tags = React.useMemo(
() =>
value
.split(',')
.map((t) => t.trim())
.filter(Boolean),
[value]
)
function commitTag(raw: string) {
const trimmed = raw.trim()
if (!trimmed) return
const next = [...tags, trimmed].join(', ')
onChange?.(next)
setInputValue('')
}
function removeTag(index: number) {
const next = tags.filter((_, i) => i !== index).join(', ')
onChange?.(next)
}
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault()
commitTag(inputValue)
} else if (
e.key === 'Backspace' &&
inputValue === '' &&
tags.length > 0
) {
removeTag(tags.length - 1)
}
}
function handleBlur() {
if (inputValue.trim()) {
commitTag(inputValue)
}
}
function handlePaste(e: React.ClipboardEvent<HTMLInputElement>) {
const pasted = e.clipboardData.getData('text')
if (pasted.includes(',')) {
e.preventDefault()
const newTags = pasted
.split(',')
.map((t) => t.trim())
.filter(Boolean)
const next = [...tags, ...newTags].join(', ')
onChange?.(next)
setInputValue('')
}
}
return (
<div
className={cn(
'flex min-h-9 w-full flex-wrap items-center gap-1.5 rounded-lg border border-input bg-transparent px-3 py-1.5 text-sm transition-colors',
'focus-within:ring-1 focus-within:ring-ring',
disabled && 'cursor-not-allowed opacity-50',
className
)}
onClick={() => inputRef.current?.focus()}
>
{tags.map((tag, i) => (
<span
key={`${tag}-${i}`}
className="inline-flex items-center gap-1 rounded-full bg-secondary px-2 py-0.5 text-xs font-medium text-secondary-foreground"
>
{tag}
{!disabled && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
removeTag(i)
}}
className="rounded-full opacity-60 hover:opacity-100 focus:outline-none"
>
<X className="h-3 w-3" />
</button>
)}
</span>
))}
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
onPaste={handlePaste}
placeholder={tags.length === 0 ? placeholder : undefined}
disabled={disabled}
className="min-w-[80px] flex-1 bg-transparent outline-none placeholder:text-muted-foreground"
/>
</div>
)
}
)
TagInput.displayName = 'TagInput'
export { TagInput }