feat: add label position (inside/outside) and text size to Zone modal
- Label position toggle: inside (default) or outside the border - Outside mode renders the label above/below the zone based on text_position - Text size selector: 10/12/14/16/18/20px (default 12) - Both fields persisted in custom_colors (no backend schema change needed) - 8 new frontend tests, 1 new backend test
This commit is contained in:
@@ -8,11 +8,15 @@ import type { TextPosition } from '@/types'
|
||||
|
||||
export type BorderStyle = 'solid' | 'dashed' | 'dotted' | 'double' | 'none'
|
||||
|
||||
export type LabelPosition = 'inside' | 'outside'
|
||||
|
||||
export interface GroupRectFormData {
|
||||
label: string
|
||||
font: string
|
||||
text_color: string
|
||||
text_position: TextPosition
|
||||
text_size: number
|
||||
label_position: LabelPosition
|
||||
border_color: string
|
||||
border_style: BorderStyle
|
||||
border_width: number
|
||||
@@ -28,6 +32,20 @@ const BORDER_STYLES: { value: BorderStyle; label: string; preview: string }[] =
|
||||
{ value: 'none', label: 'None', preview: ' ' },
|
||||
]
|
||||
|
||||
const TEXT_SIZES: { value: number; label: string }[] = [
|
||||
{ value: 10, label: '10' },
|
||||
{ value: 12, label: '12' },
|
||||
{ value: 14, label: '14' },
|
||||
{ value: 16, label: '16' },
|
||||
{ value: 18, label: '18' },
|
||||
{ value: 20, label: '20' },
|
||||
]
|
||||
|
||||
const LABEL_POSITIONS: { value: LabelPosition; label: string }[] = [
|
||||
{ value: 'inside', label: 'Inside' },
|
||||
{ value: 'outside', label: 'Outside' },
|
||||
]
|
||||
|
||||
const BORDER_WIDTHS: { value: number; label: string }[] = [
|
||||
{ value: 1, label: '1px' },
|
||||
{ value: 2, label: '2px' },
|
||||
@@ -41,6 +59,8 @@ const DEFAULT_FORM: GroupRectFormData = {
|
||||
font: 'inter',
|
||||
text_color: '#e6edf3',
|
||||
text_position: 'top-left',
|
||||
text_size: 12,
|
||||
label_position: 'inside',
|
||||
border_color: '#00d4ff',
|
||||
border_style: 'solid',
|
||||
border_width: 2,
|
||||
@@ -155,6 +175,31 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Label position */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Label Position</Label>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{LABEL_POSITIONS.map(({ value, label }) => {
|
||||
const isSelected = form.label_position === value
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => set('label_position', value)}
|
||||
className="flex items-center justify-center h-8 rounded text-xs transition-colors"
|
||||
style={{
|
||||
background: isSelected ? '#00d4ff22' : '#21262d',
|
||||
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
||||
color: isSelected ? '#00d4ff' : '#8b949e',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Colors */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Colors</Label>
|
||||
@@ -179,6 +224,32 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Text size */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Text Size</Label>
|
||||
<div className="grid grid-cols-6 gap-1">
|
||||
{TEXT_SIZES.map(({ value, label }) => {
|
||||
const isSelected = form.text_size === value
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => set('text_size', value)}
|
||||
className="flex items-center justify-center h-8 rounded transition-colors"
|
||||
style={{
|
||||
background: isSelected ? '#00d4ff22' : '#21262d',
|
||||
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
||||
color: isSelected ? '#00d4ff' : '#8b949e',
|
||||
fontSize: value,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Border style */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Border Style</Label>
|
||||
|
||||
@@ -124,6 +124,84 @@ describe('GroupRectModal', () => {
|
||||
expect(submitted.border_style).toBe('dotted')
|
||||
})
|
||||
|
||||
it('renders Label Position section with inside/outside options', () => {
|
||||
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||
expect(screen.getByText('Label Position')).toBeDefined()
|
||||
expect(screen.getByText('Inside')).toBeDefined()
|
||||
expect(screen.getByText('Outside')).toBeDefined()
|
||||
})
|
||||
|
||||
it('defaults label_position to inside', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||
fireEvent.click(screen.getByText('Add'))
|
||||
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
|
||||
expect(submitted.label_position).toBe('inside')
|
||||
})
|
||||
|
||||
it('selects outside label position on click', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||
fireEvent.click(screen.getByText('Outside'))
|
||||
fireEvent.click(screen.getByText('Add'))
|
||||
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
|
||||
expect(submitted.label_position).toBe('outside')
|
||||
})
|
||||
|
||||
it('pre-fills label_position from initial prop', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(
|
||||
<GroupRectModal
|
||||
open
|
||||
onClose={vi.fn()}
|
||||
onSubmit={onSubmit}
|
||||
initial={{ label_position: 'outside' }}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByText('Add'))
|
||||
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
|
||||
expect(submitted.label_position).toBe('outside')
|
||||
})
|
||||
|
||||
it('renders Text Size section with 6 options', () => {
|
||||
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||
expect(screen.getByText('Text Size')).toBeDefined()
|
||||
expect(screen.getByText('10')).toBeDefined()
|
||||
expect(screen.getByText('20')).toBeDefined()
|
||||
})
|
||||
|
||||
it('defaults text_size to 12', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||
fireEvent.click(screen.getByText('Add'))
|
||||
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
|
||||
expect(submitted.text_size).toBe(12)
|
||||
})
|
||||
|
||||
it('selects text size on click', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||
fireEvent.click(screen.getByText('18'))
|
||||
fireEvent.click(screen.getByText('Add'))
|
||||
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
|
||||
expect(submitted.text_size).toBe(18)
|
||||
})
|
||||
|
||||
it('pre-fills text_size from initial prop', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(
|
||||
<GroupRectModal
|
||||
open
|
||||
onClose={vi.fn()}
|
||||
onSubmit={onSubmit}
|
||||
initial={{ text_size: 16 }}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByText('Add'))
|
||||
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
|
||||
expect(submitted.text_size).toBe(16)
|
||||
})
|
||||
|
||||
it('renders Border Width section with 5 options', () => {
|
||||
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||
expect(screen.getByText('Border Width')).toBeDefined()
|
||||
|
||||
Reference in New Issue
Block a user