mirror of
https://github.com/ZSeven-W/openpencil.git
synced 2026-06-01 03:14:29 +07:00
feat(panels): split text Layout and Typography into separate sections
- Add readOnly prop to NumberInput for non-editable dimension display - Extract text Layout section (dimensions, fill/hug, resizing toggles) into text-layout-section.tsx - Enhance Typography section with line height, letter spacing, vertical alignment controls - Reorder property panel: Size → Layout → Appearance → Fill → Stroke → Typography → Effects - Add hideWH prop to SizeSection to avoid duplicate W/H inputs
This commit is contained in:
parent
154b4a57a4
commit
8df0b27d2b
5 changed files with 411 additions and 50 deletions
|
|
@ -12,6 +12,7 @@ import FillSection from './fill-section'
|
|||
import StrokeSection from './stroke-section'
|
||||
import AppearanceSection from './appearance-section'
|
||||
import TextSection from './text-section'
|
||||
import TextLayoutSection from './text-layout-section'
|
||||
import EffectsSection from './effects-section'
|
||||
import ExportSection from './export-section'
|
||||
|
||||
|
|
@ -255,6 +256,7 @@ export default function PropertyPanel() {
|
|||
cornerRadius={
|
||||
'cornerRadius' in displayNode ? displayNode.cornerRadius : undefined
|
||||
}
|
||||
hideWH={hasLayout || isText}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -267,6 +269,21 @@ export default function PropertyPanel() {
|
|||
</>
|
||||
)}
|
||||
|
||||
{isText && displayNode.type === 'text' && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="px-3 py-2">
|
||||
<TextLayoutSection node={displayNode} onUpdate={handleUpdate} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="px-3 py-2">
|
||||
<AppearanceSection node={displayNode} onUpdate={handleUpdate} />
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{hasFill && (
|
||||
|
|
@ -293,9 +310,11 @@ export default function PropertyPanel() {
|
|||
</>
|
||||
)}
|
||||
|
||||
<div className="px-3 py-2">
|
||||
<AppearanceSection node={displayNode} onUpdate={handleUpdate} />
|
||||
</div>
|
||||
{isText && displayNode.type === 'text' && (
|
||||
<div className="px-3 py-2">
|
||||
<TextSection node={displayNode} onUpdate={handleUpdate} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasEffects && (
|
||||
<>
|
||||
|
|
@ -309,15 +328,6 @@ export default function PropertyPanel() {
|
|||
</>
|
||||
)}
|
||||
|
||||
{isText && displayNode.type === 'text' && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="px-3 py-2">
|
||||
<TextSection node={displayNode} onUpdate={handleUpdate} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
<div className="px-3 py-2">
|
||||
<ExportSection nodeId={node.id} nodeName={node.name ?? node.type} />
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ interface SizeSectionProps {
|
|||
onUpdate: (updates: Partial<PenNode>) => void
|
||||
hasCornerRadius?: boolean
|
||||
cornerRadius?: number | [number, number, number, number]
|
||||
hideWH?: boolean
|
||||
}
|
||||
|
||||
export default function SizeSection({
|
||||
|
|
@ -15,6 +16,7 @@ export default function SizeSection({
|
|||
onUpdate,
|
||||
hasCornerRadius,
|
||||
cornerRadius,
|
||||
hideWH,
|
||||
}: SizeSectionProps) {
|
||||
const info = nodeRenderInfo.get(node.id)
|
||||
const offsetX = info?.parentOffsetX ?? 0
|
||||
|
|
@ -40,7 +42,12 @@ export default function SizeSection({
|
|||
: 0
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<span className=" text-[11px] font-medium text-foreground ">
|
||||
Position
|
||||
</span>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
|
||||
<NumberInput
|
||||
label="X"
|
||||
value={Math.round(x)}
|
||||
|
|
@ -51,7 +58,7 @@ export default function SizeSection({
|
|||
value={Math.round(y)}
|
||||
onChange={(v) => onUpdate({ y: v - offsetY })}
|
||||
/>
|
||||
{width !== undefined && (
|
||||
{!hideWH && width !== undefined && (
|
||||
<NumberInput
|
||||
label="W"
|
||||
value={Math.round(width)}
|
||||
|
|
@ -61,7 +68,7 @@ export default function SizeSection({
|
|||
min={1}
|
||||
/>
|
||||
)}
|
||||
{height !== undefined && (
|
||||
{!hideWH && height !== undefined && (
|
||||
<NumberInput
|
||||
label="H"
|
||||
value={Math.round(height)}
|
||||
|
|
@ -88,5 +95,6 @@ export default function SizeSection({
|
|||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
235
src/components/panels/text-layout-section.tsx
Normal file
235
src/components/panels/text-layout-section.tsx
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
import NumberInput from '@/components/shared/number-input'
|
||||
import SectionHeader from '@/components/shared/section-header'
|
||||
import {
|
||||
MoveHorizontal,
|
||||
WrapText,
|
||||
Maximize2,
|
||||
Check,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { PenNode, TextNode, SizingBehavior } from '@/types/pen'
|
||||
|
||||
interface TextLayoutSectionProps {
|
||||
node: TextNode
|
||||
onUpdate: (updates: Partial<PenNode>) => void
|
||||
}
|
||||
|
||||
type TextResizing = 'auto' | 'fixed-width' | 'fixed-width-height'
|
||||
|
||||
function resolveTextGrowth(node: TextNode): TextResizing {
|
||||
if (node.textGrowth) return node.textGrowth
|
||||
const w = node.width
|
||||
if (typeof w === 'number' && w > 0) return 'fixed-width'
|
||||
if (typeof w === 'string' && w.startsWith('fill_container')) return 'fixed-width'
|
||||
return 'auto'
|
||||
}
|
||||
|
||||
function extractNumericSize(value: SizingBehavior | undefined): number {
|
||||
if (typeof value === 'number') return value
|
||||
if (typeof value === 'string') {
|
||||
const match = value.match(/\((\d+)\)/)
|
||||
if (match) return parseInt(match[1], 10)
|
||||
}
|
||||
return 100
|
||||
}
|
||||
|
||||
function ResizingToggle({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
title,
|
||||
}: {
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
children: React.ReactNode
|
||||
title: string
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'h-7 flex-1 flex items-center justify-center gap-1 rounded text-[10px] transition-colors',
|
||||
active
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-secondary text-muted-foreground hover:text-foreground hover:bg-accent',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function SizingCheckbox({
|
||||
label,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
checked: boolean
|
||||
onChange: (checked: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<label className="flex items-center gap-1.5 cursor-pointer group">
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={cn(
|
||||
'w-4 h-4 rounded-[3px] border-[1.5px] flex items-center justify-center transition-colors shrink-0',
|
||||
checked
|
||||
? 'bg-primary border-primary'
|
||||
: 'border-muted-foreground/40 group-hover:border-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{checked && (
|
||||
<Check className="w-3 h-3 text-primary-foreground" strokeWidth={3} />
|
||||
)}
|
||||
</button>
|
||||
<span className="text-[11px] text-muted-foreground select-none">
|
||||
{label}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
export default function TextLayoutSection({
|
||||
node,
|
||||
onUpdate,
|
||||
}: TextLayoutSectionProps) {
|
||||
const resizing = resolveTextGrowth(node)
|
||||
const widthStr = typeof node.width === 'string' ? node.width : ''
|
||||
const heightStr = typeof node.height === 'string' ? node.height : ''
|
||||
const fillWidth = widthStr.startsWith('fill_container')
|
||||
const fillHeight = heightStr.startsWith('fill_container')
|
||||
const numericWidth = typeof node.width === 'number' && node.width > 0 ? node.width : undefined
|
||||
const numericHeight = typeof node.height === 'number' && node.height > 0 ? node.height : undefined
|
||||
const fallbackW = extractNumericSize(node.width)
|
||||
const fallbackH = extractNumericSize(node.height)
|
||||
|
||||
const handleResizingChange = (mode: TextResizing) => {
|
||||
const updates: Record<string, unknown> = { textGrowth: mode }
|
||||
switch (mode) {
|
||||
case 'auto':
|
||||
updates.width = 0
|
||||
updates.height = 0
|
||||
break
|
||||
case 'fixed-width':
|
||||
if (!fillWidth && (typeof node.width !== 'number' || node.width <= 0)) {
|
||||
updates.width = fallbackW > 0 ? fallbackW : 200
|
||||
}
|
||||
if (!fillHeight && (typeof node.height !== 'number' || node.height <= 0)) {
|
||||
updates.height = fallbackH > 0 ? fallbackH : 44
|
||||
}
|
||||
break
|
||||
case 'fixed-width-height':
|
||||
if (!fillWidth && (typeof node.width !== 'number' || node.width <= 0)) {
|
||||
updates.width = fallbackW > 0 ? fallbackW : 200
|
||||
}
|
||||
if (!fillHeight && (typeof node.height !== 'number' || node.height <= 0)) {
|
||||
updates.height = fallbackH > 0 ? fallbackH : 100
|
||||
}
|
||||
break
|
||||
}
|
||||
onUpdate(updates as Partial<PenNode>)
|
||||
}
|
||||
|
||||
// Always show dimensions — read-only when not directly editable
|
||||
const canEditWidth = resizing !== 'auto' && !fillWidth && numericWidth !== undefined
|
||||
const canEditHeight = resizing === 'fixed-width-height' && !fillHeight && numericHeight !== undefined
|
||||
// Display value: prefer numeric, fallback to extracted size from fill_container(N)
|
||||
const displayW = numericWidth ?? fallbackW
|
||||
const displayH = numericHeight ?? fallbackH
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<SectionHeader title="Layout" />
|
||||
|
||||
{/* Dimensions — always visible, read-only when auto/fill */}
|
||||
<div>
|
||||
<span className="text-[10px] text-muted-foreground mb-1 block">
|
||||
Dimensions
|
||||
</span>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
<NumberInput
|
||||
label="W"
|
||||
value={Math.round(displayW)}
|
||||
onChange={(v) =>
|
||||
onUpdate({ width: v } as Partial<PenNode>)
|
||||
}
|
||||
min={1}
|
||||
readOnly={!canEditWidth}
|
||||
/>
|
||||
<NumberInput
|
||||
label="H"
|
||||
value={Math.round(displayH)}
|
||||
onChange={(v) =>
|
||||
onUpdate({ height: v } as Partial<PenNode>)
|
||||
}
|
||||
min={1}
|
||||
readOnly={!canEditHeight}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fill Width / Fill Height */}
|
||||
{resizing !== 'auto' && (
|
||||
<div className="grid grid-cols-2 gap-y-1.5">
|
||||
<SizingCheckbox
|
||||
label="Fill Width"
|
||||
checked={fillWidth}
|
||||
onChange={(v) =>
|
||||
onUpdate({
|
||||
width: v ? 'fill_container' : (fallbackW > 0 ? fallbackW : 200),
|
||||
} as Partial<PenNode>)
|
||||
}
|
||||
/>
|
||||
<SizingCheckbox
|
||||
label="Fill Height"
|
||||
checked={fillHeight}
|
||||
onChange={(v) =>
|
||||
onUpdate({
|
||||
height: v ? 'fill_container' : (fallbackH > 0 ? fallbackH : 100),
|
||||
} as Partial<PenNode>)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Resizing mode */}
|
||||
<div>
|
||||
<span className="text-[10px] text-muted-foreground mb-1 block">
|
||||
Resizing
|
||||
</span>
|
||||
<div className="flex gap-0.5">
|
||||
<ResizingToggle
|
||||
active={resizing === 'auto'}
|
||||
onClick={() => handleResizingChange('auto')}
|
||||
title="Auto Width — text expands horizontally"
|
||||
>
|
||||
<MoveHorizontal className="w-3 h-3" />
|
||||
<span>Auto W</span>
|
||||
</ResizingToggle>
|
||||
<ResizingToggle
|
||||
active={resizing === 'fixed-width'}
|
||||
onClick={() => handleResizingChange('fixed-width')}
|
||||
title="Auto Height — fixed width, height auto-sizes"
|
||||
>
|
||||
<WrapText className="w-3 h-3" />
|
||||
<span>Auto H</span>
|
||||
</ResizingToggle>
|
||||
<ResizingToggle
|
||||
active={resizing === 'fixed-width-height'}
|
||||
onClick={() => handleResizingChange('fixed-width-height')}
|
||||
title="Fixed Size — both width and height are fixed"
|
||||
>
|
||||
<Maximize2 className="w-3 h-3" />
|
||||
<span>Fixed</span>
|
||||
</ResizingToggle>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -7,7 +7,15 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { AlignLeft, AlignCenter, AlignRight, AlignJustify } from 'lucide-react'
|
||||
import {
|
||||
AlignLeft,
|
||||
AlignCenter,
|
||||
AlignRight,
|
||||
AlignJustify,
|
||||
AlignVerticalJustifyStart,
|
||||
AlignVerticalJustifyCenter,
|
||||
AlignVerticalJustifyEnd,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { PenNode, TextNode } from '@/types/pen'
|
||||
|
||||
|
|
@ -35,13 +43,58 @@ const WEIGHT_OPTIONS = [
|
|||
{ value: '900', label: 'Black' },
|
||||
]
|
||||
|
||||
const ALIGN_OPTIONS = [
|
||||
const H_ALIGN_OPTIONS = [
|
||||
{ value: 'left', icon: AlignLeft, label: 'Align left' },
|
||||
{ value: 'center', icon: AlignCenter, label: 'Align center' },
|
||||
{ value: 'right', icon: AlignRight, label: 'Align right' },
|
||||
{ value: 'justify', icon: AlignJustify, label: 'Justify' },
|
||||
]
|
||||
|
||||
const V_ALIGN_OPTIONS = [
|
||||
{ value: 'top', icon: AlignVerticalJustifyStart, label: 'Top' },
|
||||
{ value: 'middle', icon: AlignVerticalJustifyCenter, label: 'Middle' },
|
||||
{ value: 'bottom', icon: AlignVerticalJustifyEnd, label: 'Bottom' },
|
||||
]
|
||||
|
||||
const LineHeightIcon = (
|
||||
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round">
|
||||
<line x1="1" y1="2" x2="1" y2="10" />
|
||||
<polyline points="3,4 1,2 -1,4" transform="translate(0,0)" />
|
||||
<polyline points="3,8 1,10 -1,8" transform="translate(0,0)" />
|
||||
<line x1="5" y1="6" x2="11" y2="6" />
|
||||
<line x1="5" y1="3" x2="9" y2="3" />
|
||||
<line x1="5" y1="9" x2="9" y2="9" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
function AlignButton({
|
||||
active,
|
||||
onClick,
|
||||
icon: Icon,
|
||||
label,
|
||||
}: {
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
label: string
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'h-6 w-6 flex items-center justify-center rounded transition-colors',
|
||||
active
|
||||
? 'bg-secondary text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/50',
|
||||
)}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default function TextSection({
|
||||
node,
|
||||
onUpdate,
|
||||
|
|
@ -49,12 +102,16 @@ export default function TextSection({
|
|||
const fontFamily = node.fontFamily ?? 'Inter, sans-serif'
|
||||
const fontSize = node.fontSize ?? 16
|
||||
const fontWeight = String(node.fontWeight ?? '400')
|
||||
const lineHeight = node.lineHeight ?? 1.2
|
||||
const letterSpacing = node.letterSpacing ?? 0
|
||||
const textAlign = node.textAlign ?? 'left'
|
||||
const textAlignVertical = node.textAlignVertical ?? 'top'
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<SectionHeader title="Text" />
|
||||
<SectionHeader title="Typography" />
|
||||
|
||||
{/* Font family */}
|
||||
<Select
|
||||
value={fontFamily}
|
||||
onValueChange={(v) =>
|
||||
|
|
@ -73,15 +130,8 @@ export default function TextSection({
|
|||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Weight + Size */}
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
<NumberInput
|
||||
value={fontSize}
|
||||
onChange={(v) =>
|
||||
onUpdate({ fontSize: v } as Partial<PenNode>)
|
||||
}
|
||||
min={1}
|
||||
max={999}
|
||||
/>
|
||||
<Select
|
||||
value={fontWeight}
|
||||
onValueChange={(v) =>
|
||||
|
|
@ -99,29 +149,80 @@ export default function TextSection({
|
|||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<NumberInput
|
||||
label="S"
|
||||
value={fontSize}
|
||||
onChange={(v) =>
|
||||
onUpdate({ fontSize: v } as Partial<PenNode>)
|
||||
}
|
||||
min={1}
|
||||
max={999}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-0.5">
|
||||
{ALIGN_OPTIONS.map(({ value, icon: Icon, label }) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
aria-label={label}
|
||||
onClick={() =>
|
||||
onUpdate({
|
||||
textAlign: value as TextNode['textAlign'],
|
||||
} as Partial<PenNode>)
|
||||
}
|
||||
className={cn(
|
||||
'h-6 w-6 flex items-center justify-center rounded transition-colors',
|
||||
textAlign === value
|
||||
? 'bg-secondary text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/50',
|
||||
)}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
))}
|
||||
{/* Line height + Letter spacing */}
|
||||
<div className="flex items-center justify-between text-[9px] text-muted-foreground px-0.5">
|
||||
<span>Line height</span>
|
||||
<span>Letter spacing</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
<NumberInput
|
||||
icon={LineHeightIcon}
|
||||
value={Math.round(lineHeight * 100)}
|
||||
onChange={(v) =>
|
||||
onUpdate({ lineHeight: v / 100 } as Partial<PenNode>)
|
||||
}
|
||||
min={50}
|
||||
max={400}
|
||||
suffix="%"
|
||||
/>
|
||||
<NumberInput
|
||||
label="|A|"
|
||||
value={letterSpacing}
|
||||
onChange={(v) =>
|
||||
onUpdate({ letterSpacing: v } as Partial<PenNode>)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Horizontal alignment */}
|
||||
<div className="space-y-0.5">
|
||||
<span className="text-[10px] text-muted-foreground">Horizontal</span>
|
||||
<div className="flex items-center gap-0.5">
|
||||
{H_ALIGN_OPTIONS.map(({ value, icon, label }) => (
|
||||
<AlignButton
|
||||
key={value}
|
||||
active={textAlign === value}
|
||||
onClick={() =>
|
||||
onUpdate({
|
||||
textAlign: value as TextNode['textAlign'],
|
||||
} as Partial<PenNode>)
|
||||
}
|
||||
icon={icon}
|
||||
label={label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vertical alignment */}
|
||||
<div className="space-y-0.5">
|
||||
<span className="text-[10px] text-muted-foreground">Vertical</span>
|
||||
<div className="flex items-center gap-0.5">
|
||||
{V_ALIGN_OPTIONS.map(({ value, icon, label }) => (
|
||||
<AlignButton
|
||||
key={value}
|
||||
active={textAlignVertical === value}
|
||||
onClick={() =>
|
||||
onUpdate({
|
||||
textAlignVertical: value as TextNode['textAlignVertical'],
|
||||
} as Partial<PenNode>)
|
||||
}
|
||||
icon={icon}
|
||||
label={label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ interface NumberInputProps {
|
|||
icon?: React.ReactNode
|
||||
suffix?: string
|
||||
className?: string
|
||||
readOnly?: boolean
|
||||
}
|
||||
|
||||
export default function NumberInput({
|
||||
|
|
@ -25,6 +26,7 @@ export default function NumberInput({
|
|||
icon,
|
||||
suffix,
|
||||
className = '',
|
||||
readOnly = false,
|
||||
}: NumberInputProps) {
|
||||
const [localValue, setLocalValue] = useState(String(value))
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
|
|
@ -69,6 +71,7 @@ export default function NumberInput({
|
|||
}
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if (readOnly) return
|
||||
if (e.target instanceof HTMLInputElement) return
|
||||
setIsDragging(true)
|
||||
dragStartY.current = e.clientY
|
||||
|
|
@ -116,10 +119,14 @@ export default function NumberInput({
|
|||
<input
|
||||
type="text"
|
||||
value={localValue}
|
||||
onChange={(e) => setLocalValue(e.target.value)}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="w-full bg-transparent text-foreground text-[11px] px-1 py-0.5 focus:outline-none tabular-nums"
|
||||
onChange={(e) => !readOnly && setLocalValue(e.target.value)}
|
||||
onBlur={readOnly ? undefined : handleBlur}
|
||||
onKeyDown={readOnly ? undefined : handleKeyDown}
|
||||
readOnly={readOnly}
|
||||
className={cn(
|
||||
'w-full bg-transparent text-[11px] px-1 py-0.5 focus:outline-none tabular-nums',
|
||||
readOnly ? 'text-muted-foreground cursor-default' : 'text-foreground',
|
||||
)}
|
||||
/>
|
||||
{suffix && (
|
||||
<span className="text-[10px] text-muted-foreground pr-1.5 shrink-0">
|
||||
|
|
|
|||
Loading…
Reference in a new issue