Skip to content

Commit 72d993e

Browse files
committed
fix(editor): preserve decimal number input drafts
1 parent 960fdaa commit 72d993e

5 files changed

Lines changed: 106 additions & 11 deletions

File tree

docs/editor.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -695,6 +695,7 @@ See [docs/features/plugin-system.md](features/plugin-system.md) for the plugin S
695695
2. Bind it to a node prop via the module's schema (`src/core/module-engine/`).
696696
3. Use existing UI primitives (`Input`, `Select`, `Switch`, `ColorInput`, etc.).
697697
4. If the control needs token-aware autocomplete (resolving framework variables like `var(--space-md)` from a typed step label), use `TokenAwareInput` from `@site/property-controls/TokenAwareInput` — pass a `tokens` array from `useSpacingTokens()` or `useTypographyTokens()` in `tokenUtils.ts`. The component handles suggestion filtering, commit-on-Enter/Tab/blur, live-preview-on-hover (gated by the `hoverPreview` editor preference), and the Suggested/All dropdown sections. For narrow overlaid inputs (like spacing box sides), use `fieldSize="xs"`, `overlay`, and `tooltipOnOverflow`.
698+
5. Number-backed CSS controls rendered through `ClassPropertyRow` keep the user's focused text in a lexical draft. `src/admin/pages/site/panels/PropertiesPanel/ClassPropertyRow.tsx` persists only finite numbers, so transient input such as `0.` or `-` remains typeable without entering `CSSPropertyBag`; blur restores the canonical stored value.
698699

699700
## Adding a new spotlight command
700701

docs/reference/css-class-registry.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ interface StyleRule {
5959

6060
`styles` and `contextStyles` are typed `Record<string, unknown>` at the persistence boundary — narrowing happens at the publisher's `bagToCSS` (`classCss.ts`). The WRITE API (class slice, framework generators) uses the typed `CSSPropertyBag` shape from `src/core/page-tree/cssPropertyBag.ts`.
6161

62+
Number-backed fields such as `opacity` and `zIndex` stay numeric at that write boundary. `src/admin/pages/site/panels/PropertiesPanel/ClassPropertyRow.tsx` holds incomplete focused text such as `0.` or `-` in a local lexical draft and commits only finite numbers; `src/admin/pages/site/property-controls/TextControl.tsx` exposes the focus and blur boundary that releases that draft.
63+
6264
Declaration priority is stored structurally, beside the scalar property value. `stylePriorities` is a sparse property-to-`'important'` map for `styles`; `contextStylePriorities` is the equivalent sparse map keyed first by context id. The CSS importer reads priority through `CSSStyleDeclaration.getPropertyPriority()`, and the publisher appends ` !important` from this metadata. Removing a value also removes its priority entry, and tolerant persistence parsing drops orphaned or invalid priority metadata. Node inline styles deliberately keep their existing value-only shape.
6365

6466
`rawCss` is intentionally narrow. The importer uses it for sanitised `@keyframes` blocks that cannot be represented as selector declarations; the publisher emits only supported raw keyframes after its own safety gate. General arbitrary CSS strings still belong in structured `styles` / `contextStyles` entries.

src/__tests__/panels/propertiesPanel-redesign.test.tsx

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1044,6 +1044,57 @@ describe('ClassPropertyRow — token-aware properties', () => {
10441044
})
10451045
})
10461046

1047+
describe('ClassPropertyRow — number-typed CSS properties', () => {
1048+
it('keeps a typed opacity decimal intact while persisting a number', async () => {
1049+
const { nodeId, classIds } = loadSiteWithClasses(1)
1050+
const classId = classIds[0]
1051+
selectNode(nodeId)
1052+
render(<PropertiesPanel />)
1053+
fireEvent.click(screen.getByRole('button', { name: /edit class \.class-1/i }))
1054+
1055+
const opacityRow = document.querySelector('[data-testid="css-property-row-opacity"]')
1056+
const opacityInput = opacityRow?.querySelector('input') as HTMLInputElement
1057+
const user = userEvent.setup()
1058+
1059+
await user.click(opacityInput)
1060+
await user.type(opacityInput, '0')
1061+
await user.type(opacityInput, '.')
1062+
expect(opacityInput.value).toBe('0.')
1063+
1064+
await user.type(opacityInput, '8')
1065+
expect(opacityInput.value).toBe('0.8')
1066+
expect(useEditorStore.getState().site!.styleRules[classId].styles.opacity).toBe(0.8)
1067+
expect(typeof useEditorStore.getState().site!.styleRules[classId].styles.opacity).toBe('number')
1068+
1069+
await user.tab()
1070+
expect(opacityInput.value).toBe('0.8')
1071+
})
1072+
1073+
it('keeps a leading minus while typing a negative z-index', async () => {
1074+
const { nodeId, classIds } = loadSiteWithClasses(1)
1075+
const classId = classIds[0]
1076+
selectNode(nodeId)
1077+
render(<PropertiesPanel />)
1078+
fireEvent.click(screen.getByRole('button', { name: /edit class \.class-1/i }))
1079+
1080+
const zIndexRow = document.querySelector('[data-testid="css-property-row-zIndex"]')
1081+
const zIndexInput = zIndexRow?.querySelector('input') as HTMLInputElement
1082+
const user = userEvent.setup()
1083+
1084+
await user.click(zIndexInput)
1085+
await user.type(zIndexInput, '-')
1086+
expect(zIndexInput.value).toBe('-')
1087+
expect(useEditorStore.getState().site!.styleRules[classId].styles.zIndex).toBeUndefined()
1088+
1089+
await user.type(zIndexInput, '2')
1090+
expect(zIndexInput.value).toBe('-2')
1091+
expect(useEditorStore.getState().site!.styleRules[classId].styles.zIndex).toBe(-2)
1092+
1093+
await user.tab()
1094+
expect(zIndexInput.value).toBe('-2')
1095+
})
1096+
})
1097+
10471098
describe('StyleRuleComposer set style indicators', () => {
10481099
it('marks category rail icons and section headers that contain stored class styles', () => {
10491100
const { nodeId, classIds } = loadSiteWithClasses(1)

src/admin/pages/site/panels/PropertiesPanel/ClassPropertyRow.tsx

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
* Phase 3 / Task #464 / Spec #671.
1313
*/
1414

15+
import { useState } from 'react'
1516
import type { CSSPropertyBag } from '@core/page-tree'
1617
import { TextControl } from '@site/property-controls/TextControl'
1718
import { ColorControl } from '@site/property-controls/ColorControl'
@@ -79,6 +80,11 @@ export function ClassPropertyRow({
7980
const label = cssPropertyLabel(String(property))
8081
const placeholderText = placeholder !== undefined ? String(placeholder) : undefined
8182
const fonts = useEditorStore((state) => state.site?.settings.fonts ?? null)
83+
const isNumberTyped = NUMBER_TYPED_PROPS.has(property)
84+
// Numeric CSS values are persisted as numbers, but their text fields need
85+
// to retain lexical editing states such as `0.`, `-`, and `1e`. `null`
86+
// means the field is not being edited and should reflect the stored value.
87+
const [numberDraft, setNumberDraft] = useState<string | null>(null)
8288

8389
// Always read both token catalogs — hooks must run unconditionally on
8490
// every render. The selected catalog is forwarded to TokenAwareInput
@@ -92,23 +98,49 @@ export function ClassPropertyRow({
9298
? spacingTokens
9399
: []
94100

101+
const commitNumberValue = (rawValue: string) => {
102+
const trimmed = rawValue.trim()
103+
if (trimmed === '') {
104+
if (value !== undefined) onChange(property, undefined)
105+
return
106+
}
107+
108+
const parsed = Number(trimmed)
109+
if (Number.isFinite(parsed) && !Object.is(value, parsed)) {
110+
onChange(property, parsed)
111+
}
112+
}
113+
95114
// Translate a control's (propKey, val) onChange signature into a typed
96-
// CSSPropertyBag value, coercing to number when the property expects one.
115+
// CSSPropertyBag value. Number-typed properties keep the raw focused draft
116+
// in the input while finite values continue to update the canvas live.
97117
const handleControlChange = (_key: string, val: unknown) => {
98118
const nextValue = String(val ?? '')
99-
if (NUMBER_TYPED_PROPS.has(property)) {
100-
const parsed = Number(nextValue)
101-
onChange(property, Number.isFinite(parsed) && nextValue.trim() !== '' ? parsed : undefined)
119+
if (isNumberTyped) {
120+
setNumberDraft(nextValue)
121+
commitNumberValue(nextValue)
102122
return
103123
}
104124
onChange(property, nextValue)
105125
}
106126

127+
const handleNumberFocus = () => {
128+
if (isNumberTyped) setNumberDraft(String(value ?? ''))
129+
}
130+
131+
const handleNumberBlur = (nextValue: string) => {
132+
if (!isNumberTyped) return
133+
commitNumberValue(nextValue)
134+
// Returning to the stored value also canonicalizes incomplete drafts:
135+
// `0.` becomes `0`, while an uncommittable `-`/`.` is discarded.
136+
setNumberDraft(null)
137+
}
138+
107139
// Token-aware properties commit on blur via TokenAwareInput's `onCommit`.
108140
// It already returns undefined for empty input (clears the value), so
109141
// the only translation we do here is the number-typed coercion.
110142
const handleTokenCommit = (resolved: string | undefined) => {
111-
if (NUMBER_TYPED_PROPS.has(property)) {
143+
if (isNumberTyped) {
112144
if (resolved == null || resolved === '') {
113145
onChange(property, undefined)
114146
return
@@ -126,7 +158,7 @@ export function ClassPropertyRow({
126158
const handleControlPreview = (_key: string, val: unknown) => {
127159
if (!onPreview) return
128160
const nextValue = String(val ?? '')
129-
if (NUMBER_TYPED_PROPS.has(property)) {
161+
if (isNumberTyped) {
130162
const parsed = Number(nextValue)
131163
onPreview(property, Number.isFinite(parsed) && nextValue.trim() !== '' ? parsed : undefined)
132164
return
@@ -136,7 +168,7 @@ export function ClassPropertyRow({
136168

137169
const handleTokenPreview = (resolved: string | undefined) => {
138170
if (!onPreview) return
139-
if (NUMBER_TYPED_PROPS.has(property)) {
171+
if (isNumberTyped) {
140172
if (resolved == null || resolved === '') {
141173
onPreview(property, undefined)
142174
return
@@ -240,10 +272,12 @@ export function ClassPropertyRow({
240272
control = (
241273
<TextControl
242274
propKey={String(property)}
243-
value={String(value ?? '')}
275+
value={isNumberTyped && numberDraft !== null ? numberDraft : String(value ?? '')}
244276
placeholder={placeholderText}
245277
onChange={handleControlChange}
246278
label={label}
279+
onInputFocus={isNumberTyped ? handleNumberFocus : undefined}
280+
onInputBlur={isNumberTyped ? handleNumberBlur : undefined}
247281
/>
248282
)
249283
break

src/admin/pages/site/property-controls/TextControl.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import { ControlRow } from '@ui/components/ControlRow'
77
interface TextControlProps extends ControlProps<string> {
88
placeholder?: string
99
normalize?: TextControlNormalize
10+
onInputFocus?: () => void
11+
onInputBlur?: (value: string) => void
1012
}
1113

1214
export function TextControl({
@@ -19,15 +21,19 @@ export function TextControl({
1921
isOverride,
2022
disabled,
2123
layout,
24+
onInputFocus,
25+
onInputBlur,
2226
}: TextControlProps) {
2327
function handleChange(nextValue: string) {
2428
onChange(propKey, normalize === 'identifier' ? normalizeIdentifierInput(nextValue) : nextValue)
2529
}
2630

2731
function handleBlur(nextValue: string) {
28-
if (normalize !== 'identifier') return
29-
const normalized = normalizeIdentifierValue(nextValue)
30-
if (normalized !== value) onChange(propKey, normalized)
32+
if (normalize === 'identifier') {
33+
const normalized = normalizeIdentifierValue(nextValue)
34+
if (normalized !== value) onChange(propKey, normalized)
35+
}
36+
onInputBlur?.(nextValue)
3137
}
3238

3339
return (
@@ -48,6 +54,7 @@ export function TextControl({
4854
autoCapitalize={normalize === 'identifier' ? 'none' : undefined}
4955
spellCheck={normalize === 'identifier' ? false : undefined}
5056
onChange={(e) => handleChange(e.target.value)}
57+
onFocus={onInputFocus}
5158
onBlur={(e) => handleBlur(e.target.value)}
5259
/>
5360
</ControlRow>

0 commit comments

Comments
 (0)