feat: redesign Add Property as inline horizontal form with type selector (#100)
Replace the vertical grey popup with an inline horizontal form that matches existing property row styling. Fields: [name] [type dropdown] [value] [✓] [×]. Type dropdown uses icon-left design consistent with the app's Select component. Includes design file with empty and filled state frames. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
448
design/add-property-inline.pen
Normal file
448
design/add-property-inline.pen
Normal file
@@ -0,0 +1,448 @@
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "apEmpty",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"name": "Add Property — Inline Form (Empty State)",
|
||||
"theme": { "Mode": "Light" },
|
||||
"width": 320,
|
||||
"height": 180,
|
||||
"fill": "$--background",
|
||||
"layout": "vertical",
|
||||
"gap": 8,
|
||||
"padding": 12,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "apEmptyDesc",
|
||||
"name": "description",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "Inline add-property form: replaces the old grey popup. Fields are horizontal, matching existing property row layout. Shows after clicking + Add property.",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11,
|
||||
"fontWeight": "normal"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "apEmptyExisting1",
|
||||
"name": "Existing Property Row — Status",
|
||||
"width": "fill_container",
|
||||
"cornerRadius": 4,
|
||||
"padding": [4, 6],
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "apEmptyLabel1",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "STATUS",
|
||||
"fontFamily": "IBM Plex Mono",
|
||||
"fontSize": 10,
|
||||
"fontWeight": "500",
|
||||
"letterSpacing": 1.2
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "apEmptyBadge1",
|
||||
"fill": "$--accent-green-light",
|
||||
"cornerRadius": 16,
|
||||
"padding": [1, 6],
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "apEmptyBadgeText1",
|
||||
"fill": "$--accent-green",
|
||||
"content": "ACTIVE",
|
||||
"fontFamily": "IBM Plex Mono",
|
||||
"fontSize": 10,
|
||||
"fontWeight": "600",
|
||||
"letterSpacing": 1.2
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "apEmptySep",
|
||||
"name": "Separator",
|
||||
"width": "fill_container",
|
||||
"height": 1,
|
||||
"fill": "$--border"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "apEmptyForm",
|
||||
"name": "Inline Add Property Form — Empty",
|
||||
"width": "fill_container",
|
||||
"gap": 6,
|
||||
"padding": [4, 6],
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "apEmptyNameInput",
|
||||
"name": "Name Input",
|
||||
"width": 90,
|
||||
"height": 26,
|
||||
"fill": "$--muted",
|
||||
"cornerRadius": 4,
|
||||
"stroke": { "align": "inside", "thickness": 1, "fill": "$--border" },
|
||||
"padding": [4, 6],
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "apEmptyNamePlaceholder",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "Name",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "normal"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "apEmptyTypeSelect",
|
||||
"name": "Type Selector",
|
||||
"width": 72,
|
||||
"height": 26,
|
||||
"fill": "$--muted",
|
||||
"cornerRadius": 4,
|
||||
"stroke": { "align": "inside", "thickness": 1, "fill": "$--border" },
|
||||
"padding": [4, 6],
|
||||
"gap": 4,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "apEmptyTypeIcon",
|
||||
"width": 12,
|
||||
"height": 12,
|
||||
"iconFontName": "type",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "$--muted-foreground"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "apEmptyTypePlaceholder",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "Text",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "normal"
|
||||
},
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "apEmptyTypeChevron",
|
||||
"width": 10,
|
||||
"height": 10,
|
||||
"iconFontName": "chevron-down",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "$--muted-foreground"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "apEmptyValueInput",
|
||||
"name": "Value Input",
|
||||
"width": "fill_container",
|
||||
"height": 26,
|
||||
"fill": "$--muted",
|
||||
"cornerRadius": 4,
|
||||
"stroke": { "align": "inside", "thickness": 1, "fill": "$--border" },
|
||||
"padding": [4, 6],
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "apEmptyValuePlaceholder",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "Value",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "normal"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "apEmptyConfirmBtn",
|
||||
"name": "Confirm Button",
|
||||
"width": 24,
|
||||
"height": 24,
|
||||
"fill": "$--primary",
|
||||
"cornerRadius": 4,
|
||||
"justifyContent": "center",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "apEmptyConfirmIcon",
|
||||
"width": 14,
|
||||
"height": 14,
|
||||
"iconFontName": "check",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "#ffffff"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "apEmptyCancelBtn",
|
||||
"name": "Cancel Button",
|
||||
"width": 24,
|
||||
"height": 24,
|
||||
"cornerRadius": 4,
|
||||
"stroke": { "align": "inside", "thickness": 1, "fill": "$--border" },
|
||||
"justifyContent": "center",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "apEmptyCancelIcon",
|
||||
"width": 14,
|
||||
"height": 14,
|
||||
"iconFontName": "x",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "$--muted-foreground"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "apFilled",
|
||||
"x": 400,
|
||||
"y": 0,
|
||||
"name": "Add Property — Inline Form (Filled State)",
|
||||
"theme": { "Mode": "Light" },
|
||||
"width": 320,
|
||||
"height": 180,
|
||||
"fill": "$--background",
|
||||
"layout": "vertical",
|
||||
"gap": 8,
|
||||
"padding": 12,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "apFilledDesc",
|
||||
"name": "description",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "Filled state: user has typed a property name, selected a type (Date), and entered a value. Confirm button is enabled (blue). The form looks like the property row it will create.",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11,
|
||||
"fontWeight": "normal"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "apFilledExisting1",
|
||||
"name": "Existing Property Row — Status",
|
||||
"width": "fill_container",
|
||||
"cornerRadius": 4,
|
||||
"padding": [4, 6],
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "apFilledLabel1",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "STATUS",
|
||||
"fontFamily": "IBM Plex Mono",
|
||||
"fontSize": 10,
|
||||
"fontWeight": "500",
|
||||
"letterSpacing": 1.2
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "apFilledBadge1",
|
||||
"fill": "$--accent-green-light",
|
||||
"cornerRadius": 16,
|
||||
"padding": [1, 6],
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "apFilledBadgeText1",
|
||||
"fill": "$--accent-green",
|
||||
"content": "ACTIVE",
|
||||
"fontFamily": "IBM Plex Mono",
|
||||
"fontSize": 10,
|
||||
"fontWeight": "600",
|
||||
"letterSpacing": 1.2
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "apFilledSep",
|
||||
"name": "Separator",
|
||||
"width": "fill_container",
|
||||
"height": 1,
|
||||
"fill": "$--border"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "apFilledForm",
|
||||
"name": "Inline Add Property Form — Filled",
|
||||
"width": "fill_container",
|
||||
"gap": 6,
|
||||
"padding": [4, 6],
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "apFilledNameInput",
|
||||
"name": "Name Input (filled)",
|
||||
"width": 90,
|
||||
"height": 26,
|
||||
"fill": "$--muted",
|
||||
"cornerRadius": 4,
|
||||
"stroke": { "align": "inside", "thickness": 1, "fill": "$--primary" },
|
||||
"padding": [4, 6],
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "apFilledNameText",
|
||||
"fill": "$--foreground",
|
||||
"content": "deadline",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "normal"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "apFilledTypeSelect",
|
||||
"name": "Type Selector (Date selected)",
|
||||
"width": 72,
|
||||
"height": 26,
|
||||
"fill": "$--muted",
|
||||
"cornerRadius": 4,
|
||||
"stroke": { "align": "inside", "thickness": 1, "fill": "$--border" },
|
||||
"padding": [4, 6],
|
||||
"gap": 4,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "apFilledTypeIcon",
|
||||
"width": 12,
|
||||
"height": 12,
|
||||
"iconFontName": "calendar",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "$--muted-foreground"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "apFilledTypeText",
|
||||
"fill": "$--foreground",
|
||||
"content": "Date",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "normal"
|
||||
},
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "apFilledTypeChevron",
|
||||
"width": 10,
|
||||
"height": 10,
|
||||
"iconFontName": "chevron-down",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "$--muted-foreground"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "apFilledValueInput",
|
||||
"name": "Value Input (filled)",
|
||||
"width": "fill_container",
|
||||
"height": 26,
|
||||
"fill": "$--muted",
|
||||
"cornerRadius": 4,
|
||||
"stroke": { "align": "inside", "thickness": 1, "fill": "$--primary" },
|
||||
"padding": [4, 6],
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "apFilledValueText",
|
||||
"fill": "$--foreground",
|
||||
"content": "2026-03-15",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "normal"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "apFilledConfirmBtn",
|
||||
"name": "Confirm Button (enabled)",
|
||||
"width": 24,
|
||||
"height": 24,
|
||||
"fill": "$--primary",
|
||||
"cornerRadius": 4,
|
||||
"justifyContent": "center",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "apFilledConfirmIcon",
|
||||
"width": 14,
|
||||
"height": 14,
|
||||
"iconFontName": "check",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "#ffffff"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "apFilledCancelBtn",
|
||||
"name": "Cancel Button",
|
||||
"width": 24,
|
||||
"height": 24,
|
||||
"cornerRadius": 4,
|
||||
"stroke": { "align": "inside", "thickness": 1, "fill": "$--border" },
|
||||
"justifyContent": "center",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "apFilledCancelIcon",
|
||||
"width": 14,
|
||||
"height": 14,
|
||||
"iconFontName": "x",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "$--muted-foreground"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"variables": {
|
||||
"--background": { "type": "color", "value": [{ "value": "#ffffff" }] },
|
||||
"--foreground": { "type": "color", "value": [{ "value": "#37352F" }] },
|
||||
"--muted": { "type": "color", "value": [{ "value": "#F0F0EF" }] },
|
||||
"--muted-foreground": { "type": "color", "value": [{ "value": "#9B9A97" }] },
|
||||
"--border": { "type": "color", "value": [{ "value": "#E9E9E7" }] },
|
||||
"--primary": { "type": "color", "value": [{ "value": "#155DFF" }] },
|
||||
"--accent-green": { "type": "color", "value": [{ "value": "#38A169" }] },
|
||||
"--accent-green-light": { "type": "color", "value": [{ "value": "rgba(56, 161, 105, 0.1)" }] }
|
||||
}
|
||||
}
|
||||
@@ -224,6 +224,7 @@ describe('DynamicPropertiesPanel', () => {
|
||||
fireEvent.click(screen.getByText('+ Add property'))
|
||||
expect(screen.getByPlaceholderText('Property name')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('Value')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('add-property-type-trigger')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('adds property via the add form', () => {
|
||||
@@ -240,7 +241,7 @@ describe('DynamicPropertiesPanel', () => {
|
||||
const valueInput = screen.getByPlaceholderText('Value')
|
||||
fireEvent.change(keyInput, { target: { value: 'priority' } })
|
||||
fireEvent.change(valueInput, { target: { value: 'high' } })
|
||||
fireEvent.click(screen.getByText('Add'))
|
||||
fireEvent.click(screen.getByTestId('add-property-confirm'))
|
||||
expect(onAddProperty).toHaveBeenCalledWith('priority', 'high')
|
||||
})
|
||||
|
||||
@@ -479,7 +480,7 @@ describe('DynamicPropertiesPanel', () => {
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByText('+ Add property'))
|
||||
fireEvent.click(screen.getByText('Cancel'))
|
||||
fireEvent.click(screen.getByTestId('add-property-cancel'))
|
||||
expect(screen.getByText('+ Add property')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
|
||||
import { countWords } from '../utils/wikilinks'
|
||||
import { Check, X, Type, Calendar, ToggleLeft, Circle, Link } from 'lucide-react'
|
||||
import {
|
||||
type PropertyDisplayMode,
|
||||
getEffectiveDisplayMode,
|
||||
@@ -203,29 +204,68 @@ function DisplayModeSelector({ propKey, currentMode, autoMode, onSelect }: {
|
||||
)
|
||||
}
|
||||
|
||||
function AddPropertyForm({ onAdd, onCancel }: { onAdd: (key: string, value: string) => void; onCancel: () => void }) {
|
||||
const DISPLAY_MODE_ICONS: Record<PropertyDisplayMode, typeof Type> = {
|
||||
text: Type, date: Calendar, boolean: ToggleLeft, status: Circle, url: Link,
|
||||
}
|
||||
|
||||
function AddPropertyForm({ onAdd, onCancel }: {
|
||||
onAdd: (key: string, value: string, displayMode: PropertyDisplayMode) => void; onCancel: () => void
|
||||
}) {
|
||||
const [newKey, setNewKey] = useState('')
|
||||
const [newValue, setNewValue] = useState('')
|
||||
const [displayMode, setDisplayMode] = useState<PropertyDisplayMode>('text')
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && newKey.trim()) onAdd(newKey, newValue)
|
||||
if (e.key === 'Enter' && newKey.trim()) onAdd(newKey, newValue, displayMode)
|
||||
else if (e.key === 'Escape') onCancel()
|
||||
}
|
||||
|
||||
const Icon = DISPLAY_MODE_ICONS[displayMode]
|
||||
|
||||
return (
|
||||
<div className="mt-3 flex flex-col gap-2 rounded-md border border-border bg-muted p-3">
|
||||
<div className="mt-1 flex items-center gap-1.5 rounded px-1.5 py-1" data-testid="add-property-form">
|
||||
<input
|
||||
className="w-full rounded border border-ring bg-muted px-2 py-1 text-[12px] text-foreground outline-none focus:border-primary"
|
||||
className="h-[26px] w-[90px] shrink-0 rounded border border-border bg-muted px-1.5 text-[12px] text-foreground outline-none focus:border-primary"
|
||||
type="text" placeholder="Property name" value={newKey}
|
||||
onChange={(e) => setNewKey(e.target.value)} onKeyDown={handleKeyDown} autoFocus
|
||||
/>
|
||||
<Select value={displayMode} onValueChange={(v) => setDisplayMode(v as PropertyDisplayMode)}>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="h-[26px] w-[82px] shrink-0 gap-1 border-border bg-muted px-1.5 py-0 shadow-none"
|
||||
style={{ fontSize: 12, borderRadius: 4 }}
|
||||
data-testid="add-property-type-trigger"
|
||||
>
|
||||
<Icon className="size-3 shrink-0 text-muted-foreground" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DISPLAY_MODE_OPTIONS.map(opt => {
|
||||
const OptIcon = DISPLAY_MODE_ICONS[opt.value]
|
||||
return (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
<OptIcon className="size-3.5 text-muted-foreground" />
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<input
|
||||
className="w-full rounded border border-ring bg-muted px-2 py-1 text-[12px] text-foreground outline-none focus:border-primary"
|
||||
className="h-[26px] min-w-0 flex-1 rounded border border-border bg-muted px-1.5 text-[12px] text-foreground outline-none focus:border-primary"
|
||||
type="text" placeholder="Value" value={newValue}
|
||||
onChange={(e) => setNewValue(e.target.value)} onKeyDown={handleKeyDown}
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button size="xs" onClick={() => onAdd(newKey, newValue)} disabled={!newKey.trim()}>Add</Button>
|
||||
<Button size="xs" variant="outline" onClick={onCancel}>Cancel</Button>
|
||||
</div>
|
||||
<Button
|
||||
size="icon-xs" onClick={() => onAdd(newKey, newValue, displayMode)}
|
||||
disabled={!newKey.trim()} title="Add property"
|
||||
data-testid="add-property-confirm"
|
||||
>
|
||||
<Check className="size-3.5" />
|
||||
</Button>
|
||||
<Button size="icon-xs" variant="outline" onClick={onCancel} title="Cancel" data-testid="add-property-cancel">
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -454,9 +494,13 @@ export function DynamicPropertiesPanel({
|
||||
reconcileListUpdate(newItems, onUpdateProperty, onDeleteProperty, key)
|
||||
}, [onUpdateProperty, onDeleteProperty])
|
||||
|
||||
const handleAdd = useCallback((rawKey: string, rawValue: string) => {
|
||||
const handleAdd = useCallback((rawKey: string, rawValue: string, mode: PropertyDisplayMode) => {
|
||||
if (!rawKey.trim() || !onAddProperty) return
|
||||
onAddProperty(rawKey.trim(), parseNewValue(rawValue))
|
||||
if (mode !== 'text') {
|
||||
saveDisplayModeOverride(rawKey.trim(), mode)
|
||||
setDisplayOverrides(loadDisplayModeOverrides())
|
||||
}
|
||||
setShowAddDialog(false)
|
||||
}, [onAddProperty])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user