feat(vue): add headless NumberField primitive

- Add pointer scrubbing, keyboard stepping, mixed and bound state contracts

- Parse safe numeric expressions and preserve deprecated ScrubInput aliases

- Cover the primitive with Storybook, SDK docs, unit tests, and E2E tests
This commit is contained in:
Danila Poyarkov 2026-07-10 22:26:51 +03:00
parent f525c0f4fb
commit e8092bd561
33 changed files with 1352 additions and 369 deletions

View file

@ -6,7 +6,7 @@ function flattenPlugins(plugins: PluginOption[]): PluginOption[] {
}
const config: StorybookConfig = {
stories: ['../src/**/*.stories.@(js|ts)'],
stories: ['../src/**/*.stories.@(js|ts)', '../packages/vue/src/**/*.stories.@(js|ts)'],
addons: ['@storybook/addon-docs', '@storybook/addon-a11y', '@storybook/addon-themes'],
framework: {
name: '@storybook/vue3-vite',

View file

@ -281,7 +281,7 @@ Self-review checklist:
- Mac keyboards: use `e.code` not `e.key` for shortcuts with modifiers (Option transforms characters)
- Icons: use unplugin-icons with Iconify/Lucide (`<icon-lucide-*>`) — don't use raw SVG or Unicode symbols
- App menu (`src/components/Shell/AppMenu.vue`) — browser-only menu bar using reka-ui Menubar components; Tauri uses native menus, so menu is hidden when `IS_TAURI` is true
- Preserve established UI gotchas in nearby components before refactoring: splitter handle sizing, ScrubInput pointer ownership, section drag targets, side-panel containment, and global number-spinner styling.
- Preserve established UI gotchas in nearby components before refactoring: splitter handle sizing, NumberField/ScrubInput pointer ownership, section drag targets, side-panel containment, and global number-spinner styling.
## File format

View file

@ -14,6 +14,7 @@
- Add Design panel controls for layer blend modes and alpha, vector, and luminance masks.
- Refine Design panel foundations with 26px controls, consistently aligned action rails, shared Tailwind themes, and Storybook component states.
- Standardize Vue SDK and app override type names on the `UI` acronym, including `FontPickerUI`.
- Add a headless Vue SDK NumberField with pointer scrubbing, keyboard stepping, safe arithmetic expressions, mixed/bound states, and deprecated ScrubInput compatibility aliases.
- Add desktop image drag-and-drop into the Tauri app window.
- Add open-document discovery for live CLI and MCP automation so agents can target the intended document and page.
- Publish lower-level SceneGraph, Pen, Kiwi, Fig, and DOM/CSS functionality through clearer package boundaries for SDK and automation consumers.

View file

@ -42,4 +42,5 @@ These APIs are public, but they are more specialized than the main component and
- [useLayerTree](./use-layer-tree)
- [useToolbar](./use-toolbar)
- [usePropertyList](./use-property-list)
- [useScrubInput](./use-scrub-input)
- [useNumberField](./use-number-field)
- [useScrubInput](./use-scrub-input) (deprecated alias)

View file

@ -0,0 +1,22 @@
---
title: useNumberField
description: Context helper for custom NumberField descendants.
---
# useNumberField
`useNumberField()` reads the context provided by `NumberFieldRoot`. Use it inside custom field
parts that need the canonical value, draft, state attributes, ARIA attributes, or actions.
It throws when called outside a `NumberFieldRoot` subtree.
```ts
import { useNumberField } from '@open-pencil/vue'
const field = useNumberField()
field.actions.startEdit()
```
See [NumberField](../components/number-field) for the complete context and anatomy.
`useScrubInput()` remains as a deprecated alias.

View file

@ -1,16 +1,13 @@
---
title: useScrubInput
description: Primitive context helper for ScrubInputRoot descendants.
description: Deprecated compatibility alias for useNumberField.
---
# useScrubInput
`useScrubInput()` reads the local scrub-input context provided by `ScrubInputRoot`.
::: warning Deprecated
Use [`useNumberField()`](./use-number-field). Both names read the same NumberField context.
:::
Use it inside `ScrubInputField`, `ScrubInputDisplay`, or custom descendants that need editing and scrubbing state.
## Related APIs
- [ScrubInputRoot](../components/scrub-input-root)
- [ScrubInputField](../components/scrub-input-field)
- [ScrubInputDisplay](../components/scrub-input-display)
See [NumberField](../components/number-field) for the current state, actions, expressions, and
accessibility contract.

View file

@ -40,7 +40,8 @@ description: Component reference for headless Vue primitives in @open-pencil/vue
<SdkCard title="GradientEditorRoot" to="/programmable/sdk/api/components/gradient-editor-root" description="Root primitive for gradient editing." />
<SdkCard title="GradientEditorBar" to="/programmable/sdk/api/components/gradient-editor-bar" description="Draggable gradient bar primitive." />
<SdkCard title="GradientEditorStop" to="/programmable/sdk/api/components/gradient-editor-stop" description="Single gradient stop primitive." />
<SdkCard title="ScrubInputRoot" to="/programmable/sdk/api/components/scrub-input-root" description="Drag-to-scrub numeric input root." />
<SdkCard title="ScrubInputField" to="/programmable/sdk/api/components/scrub-input-field" description="Editing input field primitive." />
<SdkCard title="ScrubInputDisplay" to="/programmable/sdk/api/components/scrub-input-display" description="Non-editing display primitive." />
<SdkCard title="NumberField" to="/programmable/sdk/api/components/number-field" description="Numeric field anatomy with scrubbing, expressions, and keyboard stepping." />
<SdkCard title="ScrubInputRoot" to="/programmable/sdk/api/components/scrub-input-root" description="Deprecated alias for NumberFieldRoot." />
<SdkCard title="ScrubInputField" to="/programmable/sdk/api/components/scrub-input-field" description="Deprecated alias for NumberFieldInput." />
<SdkCard title="ScrubInputDisplay" to="/programmable/sdk/api/components/scrub-input-display" description="Deprecated alias for NumberFieldValue." />
</SdkCardGroup>

View file

@ -0,0 +1,104 @@
---
title: NumberField
description: Headless numeric field primitives with scrubbing, expressions, and keyboard stepping.
---
# NumberField
The NumberField family provides a headless numeric control with pointer scrubbing, mixed values,
keyboard stepping, safe arithmetic expressions, units, trailing actions, and binding-aware state.
## Anatomy
- `NumberFieldRoot` — renderless state and interaction owner
- `NumberFieldLeading` — leading label or icon
- `NumberFieldValue` — non-editing value
- `NumberFieldInput` — editing input with spinbutton semantics
- `NumberFieldUnit` — unit text
- `NumberFieldTrailing` — trailing action area
- `NumberFieldMenu` — field menu area
## Root props
<SdkPropsTable
:rows="[
{ name: 'modelValue', type: 'number | symbol', description: 'Current value or mixed sentinel.', required: true },
{ name: 'min', type: 'number', description: 'Minimum value.', default: '-Infinity' },
{ name: 'max', type: 'number', description: 'Maximum value and percentage basis.', default: 'Infinity' },
{ name: 'step', type: 'number', description: 'Scrub and Arrow-key step.', default: '1' },
{ name: 'sensitivity', type: 'number', description: 'Pointer scrub sensitivity.', default: '1' },
{ name: 'placeholder', type: 'string', description: 'Mixed-value placeholder.', default: 'Mixed' },
{ name: 'ariaLabel', type: 'string', description: 'Accessible name propagated to the root attrs and editing input.' },
{ name: 'disabled', type: 'boolean', description: 'Disables all interactions.', default: 'false' },
{ name: 'bound', type: 'boolean', description: 'Exposes bound state to slots and data attributes.', default: 'false' },
{ name: 'editPolicy', type: `'editable' | 'readonly' | 'detach-on-edit'`, description: 'Interaction policy for a bound value.', default: 'editable' }
]"
/>
## Events
<SdkEventsTable
:rows="[
{ name: 'update:modelValue', payload: 'value: number', description: 'Emitted for live numeric updates and expression results.' },
{ name: 'commit', payload: 'value: number, previous: number', description: 'Emitted once for a changed committed interaction.' },
{ name: 'editing-change', payload: 'editing: boolean', description: 'Editing state changed.' },
{ name: 'invalid', payload: 'expression: string, reason: NumberExpressionError', description: 'An invalid expression was reverted.' },
{ name: 'detach-request', payload: `source: 'edit' | 'scrub' | 'step'`, description: 'A bound detach-on-edit interaction is about to mutate.' }
]"
/>
## Root slot
The default slot receives `modelValue`, `displayValue`, `draftValue`, `placeholder`, all state
booleans, `state`, `actions`, and `attrs`. Bind `attrs` to the focusable outer element. It contains
the canonical spinbutton ARIA contract, keyboard/focus handlers, and `data-editing`,
`data-scrubbing`, `data-mixed`, `data-disabled`, and `data-bound` attributes.
## Expressions and keyboard
Committed input accepts absolute arithmetic such as `12*8+4`, relative operations such as
`+10`, `-4`, `*2`, and `/3`, and percentages such as `50%` when `max` is finite. The parser only
accepts numbers, parentheses, and `+ - * /`; it never evaluates JavaScript.
Arrow keys step by `step`. Shift multiplies the step by 10 and Alt multiplies it by 0.1. Enter
commits and Escape restores the interaction-start value.
## Example
```vue
<script setup lang="ts">
import { ref } from 'vue'
import {
NumberFieldInput,
NumberFieldLeading,
NumberFieldRoot,
NumberFieldUnit,
NumberFieldValue
} from '@open-pencil/vue'
const width = ref(120)
</script>
<template>
<NumberFieldRoot
v-slot="{ attrs, editing, actions }"
v-model="width"
:min="0"
:max="1000"
aria-label="Width"
>
<div v-bind="attrs" @pointerdown="!editing && actions.startScrub($event)">
<NumberFieldLeading>W</NumberFieldLeading>
<NumberFieldInput />
<NumberFieldValue />
<NumberFieldUnit>px</NumberFieldUnit>
</div>
</NumberFieldRoot>
</template>
```
## Compatibility
`ScrubInputRoot`, `ScrubInputField`, `ScrubInputDisplay`, and `useScrubInput()` are deprecated
aliases. They use the NumberField implementation and can be migrated without changing model or
commit semantics.

View file

@ -1,33 +1,14 @@
---
title: ScrubInputDisplay
description: Read-only display primitive for ScrubInputRoot non-editing mode.
description: Deprecated compatibility alias for NumberFieldValue.
---
# ScrubInputDisplay
`ScrubInputDisplay` renders the non-editing display for `ScrubInputRoot`.
::: warning Deprecated
Use [`NumberFieldValue`](./number-field). `ScrubInputDisplay` is an alias backed by the NumberField
implementation.
:::
It only renders while the scrub input is not in editing mode.
## Usage
Use it inside a `ScrubInputRoot` subtree.
## Props and attrs
<SdkFieldGroup>
<SdkField name="$attrs" type="span attributes">Passed through to the rendered span element.</SdkField>
</SdkFieldGroup>
## Example
```vue
<ScrubInputRoot v-model:model-value="value">
<ScrubInputDisplay class="cursor-ew-resize" />
</ScrubInputRoot>
```
## Related APIs
- [ScrubInputRoot](./scrub-input-root)
- [ScrubInputField](./scrub-input-field)
It renders outside editing mode and exposes the canonical NumberField value, mixed placeholder,
state, and actions through its default slot.

View file

@ -1,33 +1,14 @@
---
title: ScrubInputField
description: Input element primitive for ScrubInputRoot editing mode.
description: Deprecated compatibility alias for NumberFieldInput.
---
# ScrubInputField
`ScrubInputField` renders the editable input element for `ScrubInputRoot`.
::: warning Deprecated
Use [`NumberFieldInput`](./number-field). `ScrubInputField` is an alias backed by the NumberField
implementation.
:::
It only renders while the scrub input is in editing mode.
## Usage
Use it inside a `ScrubInputRoot` subtree.
## Props and attrs
<SdkFieldGroup>
<SdkField name="$attrs" type="input attributes">Passed through to the rendered input element.</SdkField>
</SdkFieldGroup>
## Example
```vue
<ScrubInputRoot v-model:model-value="value">
<ScrubInputField class="w-16" />
</ScrubInputRoot>
```
## Related APIs
- [ScrubInputRoot](./scrub-input-root)
- [ScrubInputDisplay](./scrub-input-display)
The editing element now uses `type="text"` and `inputmode="decimal"` so it can accept safe numeric
expressions while preserving spinbutton ARIA and keyboard behavior.

View file

@ -1,69 +1,25 @@
---
title: ScrubInputRoot
description: Headless root primitive for drag-to-scrub numeric input.
description: Deprecated compatibility alias for NumberFieldRoot.
---
# ScrubInputRoot
`ScrubInputRoot` is the headless root primitive for drag-to-scrub numeric input.
::: warning Deprecated
Use [`NumberFieldRoot`](./number-field). `ScrubInputRoot` is a compatibility alias backed by the
same implementation and will be removed in a future cleanup release.
:::
It manages:
Existing `modelValue`, `min`, `max`, `step`, `sensitivity`, `placeholder`, `update:modelValue`,
`commit`, and `editing-change` contracts remain available. The root slot now also exposes the
NumberField state, canonical `attrs` bag, expression input, keyboard stepping, and binding-aware
state.
- mixed-value display
- editing vs scrubbing state
- pointer-driven numeric scrubbing
- commit semantics for finished edits
## Migration
## Props
<SdkPropsTable
:rows="[
{ name: 'modelValue', type: 'number | symbol', description: 'Current numeric value or mixed sentinel.', required: true },
{ name: 'min', type: 'number | undefined', description: 'Minimum allowed value.', default: '-Infinity' },
{ name: 'max', type: 'number | undefined', description: 'Maximum allowed value.', default: 'Infinity' },
{ name: 'step', type: 'number | undefined', description: 'Scrub step multiplier.', default: '1' },
{ name: 'sensitivity', type: 'number | undefined', description: 'Pointer sensitivity multiplier.', default: '1' },
{ name: 'placeholder', type: 'string | undefined', description: 'Placeholder for mixed values.', default: 'Mixed' }
]"
/>
## Model
<SdkPropsTable
:rows="[
{ name: 'v-model:modelValue', type: 'number', description: 'Writable numeric model.', required: true }
]"
/>
## Events
<SdkEventsTable
:rows="[
{ name: 'update:modelValue', payload: 'value: number', description: 'Emitted while scrubbing or editing.' },
{ name: 'commit', payload: 'value: number, previous: number', description: 'Emitted when an edit interaction is committed.' }
]"
/>
## Slots
<SdkSlotsTable
:rows="[
{ name: 'default', props: '{ modelValue: number | symbol, displayValue: string, isMixed: boolean, editing: boolean, scrubbing: boolean, startScrub: (event: PointerEvent) => void, startEdit: () => void, commitEdit: (event: Event) => void, keydown: (event: KeyboardEvent) => void, placeholder: string }', description: 'Full scrub-input render contract.' }
]"
/>
## Example
```vue
<ScrubInputRoot v-model:model-value="value" @commit="commit" v-slot="ctx">
<div @pointerdown="ctx.startScrub">
<ScrubInputDisplay />
<ScrubInputField class="w-16" />
</div>
</ScrubInputRoot>
```diff
-import { ScrubInputRoot, ScrubInputField, ScrubInputDisplay } from '@open-pencil/vue'
+import { NumberFieldRoot, NumberFieldInput, NumberFieldValue } from '@open-pencil/vue'
```
## Related APIs
- [ScrubInputField](./scrub-input-field)
- [ScrubInputDisplay](./scrub-input-display)
See [NumberField](./number-field) for the complete anatomy and interaction contract.

View file

@ -8,7 +8,7 @@ description: Reference documentation for @open-pencil/vue components, composable
The Vue SDK reference is organized into three sections.
<SdkCardGroup>
<SdkCard title="Components" to="/programmable/sdk/api/components/" description="Headless structural primitives like CanvasRoot, ToolbarRoot, FontPickerRoot, and ScrubInputRoot." />
<SdkCard title="Components" to="/programmable/sdk/api/components/" description="Headless structural primitives like CanvasRoot, ToolbarRoot, FontPickerRoot, and NumberFieldRoot." />
<SdkCard title="Composables" to="/programmable/sdk/api/composables/" description="Editor-facing composables for context, canvas wiring, selection, commands, and property panels." />
<SdkCard title="Advanced" to="/programmable/sdk/api/advanced/" description="Lower-level helpers, primitive contexts, and specialized APIs for advanced integrations." />
</SdkCardGroup>

View file

@ -28,7 +28,7 @@ This package is organized by domain.
- `LayerTree/`
- `PageList/`
- `PropertyList/`
- `ScrubInput/`
- `NumberField/` (`ScrubInput/` remains as deprecated aliases)
- `Toolbar/`
These contain structural/headless primitives and local helpers.

View file

@ -86,8 +86,12 @@ Main structural primitives include:
- `ColorPickerRoot`
- `FillPickerRoot`
- `FontPickerRoot`
- `NumberFieldRoot` / `NumberFieldInput` / `NumberFieldValue`
These components coordinate structure and state, but do not impose app styling.
These components coordinate structure and state, but do not impose app styling. `NumberField`
adds pointer scrubbing, Arrow-key stepping, mixed/bound state attributes, and safe arithmetic
expressions such as `+10`, `*2`, `50%`, and `12*8+4`. The former `ScrubInput` API remains as
deprecated aliases during migration.
## Public API tiers
@ -135,6 +139,13 @@ These are the main APIs most SDK consumers should start with.
- `PageListRoot`
- `PropertyListRoot`
- `ToolbarRoot`
- `NumberFieldRoot`
- `NumberFieldInput`
- `NumberFieldValue`
- `NumberFieldLeading`
- `NumberFieldUnit`
- `NumberFieldTrailing`
- `NumberFieldMenu`
### Advanced API
@ -168,7 +179,8 @@ These are mostly useful when extending SDK primitives rather than building from
- `useLayerTree()`
- `useToolbar()`
- `usePropertyList()`
- `useScrubInput()`
- `useNumberField()`
- `useScrubInput()` (deprecated alias)
- `locale`
- `localeSetting`
- `setLocale()`

View file

@ -0,0 +1,143 @@
export type NumberExpressionError =
| 'empty'
| 'syntax'
| 'non-finite'
| 'percent-requires-finite-max'
| 'relative-requires-value'
export type NumberExpressionResult =
| { ok: true; value: number; kind: 'absolute' | 'relative' | 'percent' }
| { ok: false; error: NumberExpressionError }
export interface NumberExpressionOptions {
current: number
max?: number
mixed?: boolean
}
class ArithmeticParser {
private index = 0
constructor(private readonly source: string) {}
parse(): number {
const value = this.parseExpression()
this.skipWhitespace()
if (this.index !== this.source.length) throw new Error('Unexpected trailing input')
return value
}
private parseExpression(): number {
let value = this.parseTerm()
this.skipWhitespace()
let operator = this.source[this.index]
while (operator === '+' || operator === '-') {
this.index += 1
const right = this.parseTerm()
value = operator === '+' ? value + right : value - right
this.skipWhitespace()
operator = this.source[this.index]
}
return value
}
private parseTerm(): number {
let value = this.parseUnary()
this.skipWhitespace()
let operator = this.source[this.index]
while (operator === '*' || operator === '/') {
this.index += 1
const right = this.parseUnary()
value = operator === '*' ? value * right : value / right
this.skipWhitespace()
operator = this.source[this.index]
}
return value
}
private parseUnary(): number {
this.skipWhitespace()
const operator = this.source[this.index]
if (operator !== '+' && operator !== '-') return this.parsePrimary()
this.index += 1
const value = this.parseUnary()
return operator === '-' ? -value : value
}
private parsePrimary(): number {
this.skipWhitespace()
if (this.source[this.index] === '(') {
this.index += 1
const value = this.parseExpression()
this.skipWhitespace()
if (this.source[this.index] !== ')') throw new Error('Missing closing parenthesis')
this.index += 1
return value
}
const match = /^(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?/i.exec(this.source.slice(this.index))
if (!match) throw new Error('Expected number')
this.index += match[0].length
return Number(match[0])
}
private skipWhitespace() {
while (/\s/.test(this.source[this.index] ?? '')) this.index += 1
}
}
function evaluateArithmetic(source: string): NumberExpressionResult {
try {
const value = new ArithmeticParser(source).parse()
return Number.isFinite(value)
? { ok: true, value, kind: 'absolute' }
: { ok: false, error: 'non-finite' }
} catch {
return { ok: false, error: 'syntax' }
}
}
export function evaluateNumberExpression(
expression: string,
{ current, max = Infinity, mixed = false }: NumberExpressionOptions
): NumberExpressionResult {
const source = expression.trim()
if (!source) return { ok: false, error: 'empty' }
if (source.endsWith('%')) {
if (!Number.isFinite(max)) return { ok: false, error: 'percent-requires-finite-max' }
const percentage = evaluateArithmetic(source.slice(0, -1))
if (!percentage.ok) return percentage
const value = (max * percentage.value) / 100
return Number.isFinite(value)
? { ok: true, value, kind: 'percent' }
: { ok: false, error: 'non-finite' }
}
const relative = /^[+\-*/]/.test(source)
if (relative && mixed) return { ok: false, error: 'relative-requires-value' }
if (relative && !Number.isFinite(current)) return { ok: false, error: 'non-finite' }
const result = evaluateArithmetic(relative ? `(${current})${source}` : source)
return result.ok ? { ...result, kind: relative ? 'relative' : 'absolute' } : result
}
export function clampNumberValue(value: number, min = -Infinity, max = Infinity): number {
return Math.min(max, Math.max(min, value))
}
export function normalizeNumberValue(value: number): number {
return Number.isFinite(value) ? Number.parseFloat(value.toPrecision(14)) : value
}
export function stepNumberValue(
value: number,
direction: 1 | -1,
step: number,
modifiers: { shiftKey?: boolean; altKey?: boolean } = {},
min = -Infinity,
max = Infinity
): number {
const multiplier = (modifiers.shiftKey ? 10 : 1) * (modifiers.altKey ? 0.1 : 1)
return clampNumberValue(normalizeNumberValue(value + direction * step * multiplier), min, max)
}

View file

@ -0,0 +1,11 @@
export {
clampNumberValue,
evaluateNumberExpression,
normalizeNumberValue,
stepNumberValue
} from '#vue/controls/number-expression/evaluate'
export type {
NumberExpressionError,
NumberExpressionOptions,
NumberExpressionResult
} from '#vue/controls/number-expression/evaluate'

View file

@ -143,6 +143,41 @@ export { PageListRoot } from '#vue/primitives/PageList'
export { PositionControlsRoot } from '#vue/primitives/PositionControls'
export { PropertyListRoot, PropertyListItem, usePropertyList } from '#vue/primitives/PropertyList'
export type { PropertyListContext } from '#vue/primitives/PropertyList'
export {
NumberFieldRoot,
NumberFieldInput,
NumberFieldValue,
NumberFieldLeading,
NumberFieldUnit,
NumberFieldTrailing,
NumberFieldMenu,
useNumberField
} from '#vue/primitives/NumberField'
export type {
NumberFieldActions,
NumberFieldContext,
NumberFieldEditPolicy,
NumberFieldMutationSource,
NumberFieldRootAttrs,
NumberFieldRootEmits,
NumberFieldRootProps,
NumberFieldRootSlots,
NumberFieldSlotProps,
NumberFieldState,
NumberFieldStateAttrs,
NumberFieldValueSlots
} from '#vue/primitives/NumberField'
export {
clampNumberValue,
evaluateNumberExpression,
normalizeNumberValue,
stepNumberValue
} from '#vue/controls/number-expression'
export type {
NumberExpressionError,
NumberExpressionOptions,
NumberExpressionResult
} from '#vue/controls/number-expression'
export {
ScrubInputRoot,
ScrubInputField,

View file

@ -0,0 +1,150 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { expect, userEvent, within } from 'storybook/test'
import { ref } from 'vue'
import { MIXED } from '#vue/controls/node-props/use'
import NumberFieldInput from './NumberFieldInput.vue'
import NumberFieldRoot from './NumberFieldRoot.vue'
import NumberFieldValue from './NumberFieldValue.vue'
import { NumberFieldLeading, NumberFieldMenu, NumberFieldTrailing, NumberFieldUnit } from './parts'
const meta = {
title: 'Vue SDK/Primitives/NumberField',
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Headless numeric field with pointer scrubbing, keyboard stepping, arithmetic expressions, mixed values, and binding-aware state.'
}
}
}
} satisfies Meta
export default meta
type Story = StoryObj<typeof meta>
function renderStateMatrix() {
return {
components: {
NumberFieldInput,
NumberFieldLeading,
NumberFieldMenu,
NumberFieldRoot,
NumberFieldTrailing,
NumberFieldUnit,
NumberFieldValue
},
setup() {
const value = ref<number | symbol>(24)
const mixed = ref<number | symbol>(MIXED)
const disabled = ref<number | symbol>(16)
const bound = ref<number | symbol>(8)
return { value, mixed, disabled, bound }
},
template: `
<div class="w-[520px] space-y-5 rounded-lg border border-border bg-panel p-5">
<div>
<h2 class="mb-1 text-xs font-semibold">Interactive anatomy</h2>
<p class="mb-2 text-[11px] text-muted">Try +10, *2, 50%, or 12*8+4</p>
<NumberFieldRoot v-slot="{ attrs, editing, actions }" v-model="value" :min="0" :max="200" :step="2" aria-label="Interactive number field">
<div
v-bind="attrs"
data-story-control
class="flex h-control w-56 items-center rounded-panel border border-transparent bg-panel-field text-xs outline-none focus-within:border-panel-focus"
@pointerdown="!editing && actions.startScrub($event)"
>
<NumberFieldLeading class="px-2 text-muted">W</NumberFieldLeading>
<NumberFieldInput data-test-id="interactive-number-input" class="min-w-0 flex-1 border-0 bg-transparent outline-none" />
<NumberFieldValue class="min-w-0 flex-1 truncate" />
<NumberFieldUnit class="pr-1 text-muted">px</NumberFieldUnit>
<NumberFieldTrailing class="flex size-control items-center justify-center text-muted">
R
</NumberFieldTrailing>
<NumberFieldMenu class="flex size-control items-center justify-center text-muted">
M
</NumberFieldMenu>
</div>
</NumberFieldRoot>
</div>
<div class="grid grid-cols-3 gap-panel">
<div>
<p class="mb-1 text-[11px] text-muted">Mixed</p>
<NumberFieldRoot v-slot="{ attrs, editing, actions }" v-model="mixed" aria-label="Mixed number field">
<div
v-bind="attrs"
class="flex h-control items-center rounded-panel bg-panel-field px-2 text-xs"
@pointerdown="!editing && actions.startScrub($event)"
>
<NumberFieldInput class="min-w-0 flex-1 bg-transparent outline-none" />
<NumberFieldValue class="text-muted" />
</div>
</NumberFieldRoot>
</div>
<div>
<p class="mb-1 text-[11px] text-muted">Disabled</p>
<NumberFieldRoot v-slot="{ attrs }" v-model="disabled" aria-label="Disabled number field" disabled>
<div
v-bind="attrs"
class="flex h-control items-center rounded-panel bg-panel-field px-2 text-xs opacity-60"
>
<NumberFieldValue />
</div>
</NumberFieldRoot>
</div>
<div>
<p class="mb-1 text-[11px] text-muted">Bound</p>
<NumberFieldRoot v-slot="{ attrs, editing, actions }" v-model="bound" aria-label="Bound number field" bound>
<div
v-bind="attrs"
class="flex h-control items-center rounded-panel bg-panel-field px-2 text-xs text-component"
@pointerdown="!editing && actions.startScrub($event)"
>
<NumberFieldInput class="min-w-0 flex-1 bg-transparent outline-none" />
<NumberFieldValue />
<NumberFieldUnit class="ml-1">gap/md</NumberFieldUnit>
</div>
</NumberFieldRoot>
</div>
</div>
</div>
`
}
}
export const StateMatrix: Story = {
render: renderStateMatrix,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
const root = canvas.getByLabelText('Interactive number field')
await expect(root).toHaveStyle({ height: '26px' })
await userEvent.click(root)
const input = canvasElement.querySelector<HTMLInputElement>(
'[data-test-id="interactive-number-input"]'
)
if (!input) throw new Error('Expected the editing NumberField input')
await userEvent.clear(input)
await userEvent.type(input, '12*8+4{Enter}')
await expect(root).toHaveAttribute('aria-valuenow', '100')
await expect(canvas.getByLabelText('Mixed number field')).toHaveAttribute('data-mixed')
await expect(canvas.getByLabelText('Disabled number field')).toHaveAttribute('data-disabled')
await expect(canvas.getByLabelText('Bound number field')).toHaveAttribute('data-bound')
}
}
export const Editing: Story = {
render: renderStateMatrix,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
await userEvent.click(canvas.getByLabelText('Interactive number field'))
await expect(
canvasElement.querySelector('[data-test-id="interactive-number-input"]')
).toBeVisible()
}
}

View file

@ -0,0 +1,42 @@
<script setup lang="ts">
import { computed, watchEffect } from 'vue'
import { templateRef } from '@vueuse/core'
import { useNumberField } from '#vue/primitives/NumberField/context'
const ctx = useNumberField()
const inputEl = templateRef<HTMLInputElement>('inputEl')
const ariaAttrs = computed(() => ({
role: 'spinbutton' as const,
'aria-valuenow': ctx.isMixed.value ? undefined : ctx.numericValue.value,
'aria-valuemin': Number.isFinite(ctx.min.value) ? ctx.min.value : undefined,
'aria-valuemax': Number.isFinite(ctx.max.value) ? ctx.max.value : undefined,
'aria-disabled': ctx.disabled.value ? ('true' as const) : undefined,
'aria-label': ctx.rootAttrs.value['aria-label']
}))
watchEffect(() => {
ctx.inputRef.value = inputEl.value
})
defineOptions({ inheritAttrs: false })
</script>
<template>
<input
v-if="ctx.editing.value"
ref="inputEl"
v-bind="{ ...$attrs, ...ctx.stateAttrs.value, ...ariaAttrs }"
data-slot="input"
type="text"
inputmode="decimal"
autocomplete="off"
:spellcheck="false"
:disabled="ctx.disabled.value"
:value="ctx.draftValue.value"
@blur="ctx.actions.commitEdit"
@keydown.stop="ctx.actions.keydown"
@input="ctx.actions.input"
/>
</template>

View file

@ -0,0 +1,358 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { useEventListener } from '@vueuse/core'
import {
clampNumberValue,
evaluateNumberExpression,
normalizeNumberValue,
stepNumberValue
} from '#vue/controls/number-expression'
import type { NumberExpressionError } from '#vue/controls/number-expression'
import { provideNumberField } from '#vue/primitives/NumberField/context'
import type {
NumberFieldActions,
NumberFieldMutationSource,
NumberFieldRootAttrs,
NumberFieldRootEmits,
NumberFieldRootProps,
NumberFieldRootSlots,
NumberFieldSlotProps,
NumberFieldState,
NumberFieldStateAttrs
} from '#vue/primitives/NumberField/types'
import { inputValue } from '#vue/shared/dom-events'
const {
modelValue,
min = -Infinity,
max = Infinity,
step = 1,
sensitivity = 1,
placeholder = 'Mixed',
ariaLabel,
disabled: disabledProp = false,
bound: boundProp = false,
editPolicy = 'editable'
} = defineProps<NumberFieldRootProps>()
const emit = defineEmits<NumberFieldRootEmits>()
defineSlots<NumberFieldRootSlots>()
const editing = ref(false)
const scrubbing = ref(false)
const draftValue = ref('')
const inputRef = ref<HTMLInputElement | null>(null)
const invalidReason = ref<NumberExpressionError | null>(null)
const workingValue = ref(0)
const isMixed = computed(() => typeof modelValue === 'symbol')
const numericValue = computed(() => (typeof modelValue === 'number' ? modelValue : 0))
const displayValue = computed(() =>
isMixed.value ? '' : String(normalizeNumberValue(numericValue.value))
)
const disabled = computed(() => disabledProp)
const bound = computed(() => boundProp)
const minValue = computed(() => min)
const maxValue = computed(() => max)
const stepValue = computed(() => (Number.isFinite(step) && step > 0 ? step : 1))
let interactionStartValue = 0
let interactionStartedMixed = false
let detachRequested = false
let stopMove: (() => void) | undefined
let stopUp: (() => void) | undefined
let stopCancel: (() => void) | undefined
let scrubTarget: Element | undefined
let scrubPointerId: number | undefined
function canMutate(): boolean {
return !disabled.value && !(bound.value && editPolicy === 'readonly')
}
function requestMutation(source: NumberFieldMutationSource): boolean {
if (!canMutate()) return false
if (bound.value && editPolicy === 'detach-on-edit' && !detachRequested) {
detachRequested = true
emit('detach-request', source)
}
return true
}
function beginInteraction() {
interactionStartValue = numericValue.value
interactionStartedMixed = isMixed.value
workingValue.value = numericValue.value
detachRequested = false
invalidReason.value = null
}
function updateValue(value: number) {
const normalized = normalizeNumberValue(clampNumberValue(value, min, max))
workingValue.value = normalized
if (modelValue !== normalized) emit('update:modelValue', normalized)
}
function restoreInteractionValue() {
if (workingValue.value !== interactionStartValue || interactionStartedMixed !== isMixed.value) {
workingValue.value = interactionStartValue
emit('update:modelValue', interactionStartValue)
}
}
function finishCommit(value: number) {
updateValue(value)
editing.value = false
if (workingValue.value !== interactionStartValue) {
emit('commit', workingValue.value, interactionStartValue)
}
}
function startEdit() {
if (editing.value || !canMutate()) return
beginInteraction()
requestMutation('edit')
draftValue.value = isMixed.value ? '' : displayValue.value
editing.value = true
void nextTick(() => {
inputRef.value?.focus()
inputRef.value?.select()
})
}
function setDraft(value: string) {
draftValue.value = value
const absoluteNumber = /^\s*(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?\s*$/i.test(value)
if (absoluteNumber) updateValue(Number(value))
}
function onInput(event: Event) {
setDraft(inputValue(event))
}
function commitEdit() {
if (!editing.value) return
const expression = draftValue.value
const result = evaluateNumberExpression(expression, {
current: interactionStartValue,
max,
mixed: interactionStartedMixed
})
if (!result.ok) {
invalidReason.value = result.error
restoreInteractionValue()
editing.value = false
emit('invalid', expression, result.error)
return
}
finishCommit(result.value)
}
function cancelEdit() {
if (!editing.value) return
restoreInteractionValue()
invalidReason.value = null
editing.value = false
}
function stopScrubListeners() {
stopMove?.()
stopUp?.()
stopCancel?.()
stopMove = undefined
stopUp = undefined
stopCancel = undefined
if (scrubTarget && scrubPointerId != null && scrubTarget.hasPointerCapture(scrubPointerId)) {
scrubTarget.releasePointerCapture(scrubPointerId)
}
scrubTarget = undefined
scrubPointerId = undefined
if (typeof document !== 'undefined') document.body.style.cursor = ''
}
function startScrub(event: PointerEvent) {
if (!canMutate()) return
event.preventDefault()
beginInteraction()
const startX = event.clientX
let lastX = startX
let accumulated = numericValue.value
let hasMoved = false
const target = event.currentTarget instanceof Element ? event.currentTarget : undefined
scrubTarget = target
scrubPointerId = event.pointerId
target?.setPointerCapture(event.pointerId)
const listenerTarget = target ?? document
stopMove = useEventListener(listenerTarget, 'pointermove', (moveEvent: PointerEvent) => {
if (moveEvent.pointerId !== event.pointerId) return
const dx = moveEvent.clientX - lastX
lastX = moveEvent.clientX
if (!hasMoved && Math.abs(moveEvent.clientX - startX) > 2) {
if (!requestMutation('scrub')) return
hasMoved = true
scrubbing.value = true
document.body.style.cursor = 'ew-resize'
}
if (!hasMoved) return
accumulated += dx * stepValue.value * sensitivity
updateValue(accumulated)
})
const finish = (cancelled: boolean) => {
stopScrubListeners()
scrubbing.value = false
if (cancelled) {
restoreInteractionValue()
return
}
if (!hasMoved) {
startEdit()
return
}
if (workingValue.value !== interactionStartValue) {
emit('commit', workingValue.value, interactionStartValue)
}
}
stopUp = useEventListener(listenerTarget, 'pointerup', (upEvent: PointerEvent) => {
if (upEvent.pointerId === event.pointerId) finish(false)
})
stopCancel = useEventListener(listenerTarget, 'pointercancel', (cancelEvent: PointerEvent) => {
if (cancelEvent.pointerId === event.pointerId) finish(true)
})
}
function stepValueFromKeyboard(event: KeyboardEvent) {
if (event.code !== 'ArrowUp' && event.code !== 'ArrowDown') return false
if (!requestMutation('step')) return true
event.preventDefault()
if (!editing.value) beginInteraction()
const draftResult = editing.value
? evaluateNumberExpression(draftValue.value, {
current: interactionStartValue,
max,
mixed: interactionStartedMixed
})
: undefined
const base = draftResult?.ok ? draftResult.value : workingValue.value
const next = stepNumberValue(
base,
event.code === 'ArrowUp' ? 1 : -1,
stepValue.value,
event,
min,
max
)
updateValue(next)
draftValue.value = String(next)
if (!editing.value && next !== interactionStartValue) emit('commit', next, interactionStartValue)
return true
}
function onKeydown(event: KeyboardEvent) {
if (stepValueFromKeyboard(event)) return
if (event.code === 'Enter') {
event.preventDefault()
commitEdit()
} else if (event.code === 'Escape') {
event.preventDefault()
cancelEdit()
}
}
const state = computed<NumberFieldState>(() => ({
editing: editing.value,
scrubbing: scrubbing.value,
mixed: isMixed.value,
disabled: disabled.value,
bound: bound.value
}))
const stateAttrs = computed<NumberFieldStateAttrs>(() => ({
'data-editing': editing.value ? '' : undefined,
'data-scrubbing': scrubbing.value ? '' : undefined,
'data-mixed': isMixed.value ? '' : undefined,
'data-disabled': disabled.value ? '' : undefined,
'data-bound': bound.value ? '' : undefined
}))
const rootTabindex = computed<0 | -1 | undefined>(() => {
if (editing.value) return undefined
return disabled.value ? -1 : 0
})
const rootAttrs = computed<NumberFieldRootAttrs>(() => ({
...stateAttrs.value,
role: editing.value ? undefined : 'spinbutton',
tabindex: rootTabindex.value,
'aria-valuenow': editing.value || isMixed.value ? undefined : numericValue.value,
'aria-valuemin': !editing.value && Number.isFinite(min) ? min : undefined,
'aria-valuemax': !editing.value && Number.isFinite(max) ? max : undefined,
'aria-disabled': !editing.value && disabled.value ? 'true' : undefined,
'aria-label': editing.value ? undefined : ariaLabel,
onFocus: startEdit,
onKeydown
}))
const actions: NumberFieldActions = {
startScrub,
startEdit,
cancelEdit,
commitEdit,
setDraft,
input: onInput,
keydown: onKeydown
}
const slotProps = computed<NumberFieldSlotProps>(() => ({
modelValue,
displayValue: displayValue.value,
draftValue: draftValue.value,
isMixed: isMixed.value,
placeholder,
...state.value,
state: state.value,
attrs: rootAttrs.value,
actions
}))
provideNumberField({
modelValue: computed(() => modelValue),
numericValue,
displayValue,
draftValue,
isMixed,
editing,
scrubbing,
disabled,
bound,
min: minValue,
max: maxValue,
step: stepValue,
inputRef,
state,
stateAttrs,
rootAttrs,
slotProps,
actions,
invalidReason
})
watch(editing, (value) => emit('editing-change', value))
watch(
() => modelValue,
(value) => {
if (!editing.value && !scrubbing.value && typeof value === 'number') workingValue.value = value
},
{ immediate: true }
)
onBeforeUnmount(stopScrubListeners)
</script>
<template>
<slot v-bind="slotProps" />
</template>

View file

@ -0,0 +1,16 @@
<script setup lang="ts">
import { useNumberField } from '#vue/primitives/NumberField/context'
import type { NumberFieldValueSlots } from '#vue/primitives/NumberField/types'
const ctx = useNumberField()
defineSlots<NumberFieldValueSlots>()
defineOptions({ inheritAttrs: false })
</script>
<template>
<span v-if="!ctx.editing.value" v-bind="{ ...$attrs, ...ctx.stateAttrs.value }" data-slot="value">
<slot v-bind="ctx.slotProps.value" :value="ctx.displayValue.value">
{{ ctx.isMixed.value ? ctx.slotProps.value.placeholder : ctx.displayValue.value }}
</slot>
</span>
</template>

View file

@ -0,0 +1,4 @@
import { createContext } from '#vue/internal/create-context'
import type { NumberFieldContext } from '#vue/primitives/NumberField/types'
export const [useNumberField, provideNumberField] = createContext<NumberFieldContext>('NumberField')

View file

@ -0,0 +1,24 @@
export { default as NumberFieldRoot } from '#vue/primitives/NumberField/NumberFieldRoot.vue'
export { default as NumberFieldInput } from '#vue/primitives/NumberField/NumberFieldInput.vue'
export { default as NumberFieldValue } from '#vue/primitives/NumberField/NumberFieldValue.vue'
export {
NumberFieldLeading,
NumberFieldUnit,
NumberFieldTrailing,
NumberFieldMenu
} from '#vue/primitives/NumberField/parts'
export { useNumberField } from '#vue/primitives/NumberField/context'
export type {
NumberFieldActions,
NumberFieldContext,
NumberFieldEditPolicy,
NumberFieldMutationSource,
NumberFieldRootAttrs,
NumberFieldRootEmits,
NumberFieldRootProps,
NumberFieldRootSlots,
NumberFieldSlotProps,
NumberFieldState,
NumberFieldStateAttrs,
NumberFieldValueSlots
} from '#vue/primitives/NumberField/types'

View file

@ -0,0 +1,26 @@
import { defineComponent, h } from 'vue'
import { useNumberField } from '#vue/primitives/NumberField/context'
export type NumberFieldPartName = 'leading' | 'unit' | 'trailing' | 'menu'
function createNumberFieldPart(part: NumberFieldPartName) {
return defineComponent({
name: `NumberField${part[0]?.toUpperCase() ?? ''}${part.slice(1)}`,
inheritAttrs: false,
setup(_props, { attrs, slots }) {
const ctx = useNumberField()
return () =>
h(
'span',
{ ...attrs, ...ctx.stateAttrs.value, 'data-slot': part },
slots.default?.(ctx.slotProps.value)
)
}
})
}
export const NumberFieldLeading = createNumberFieldPart('leading')
export const NumberFieldUnit = createNumberFieldPart('unit')
export const NumberFieldTrailing = createNumberFieldPart('trailing')
export const NumberFieldMenu = createNumberFieldPart('menu')

View file

@ -0,0 +1,106 @@
import type { ComputedRef, Ref, VNode } from 'vue'
import type { NumberExpressionError } from '#vue/controls/number-expression'
export type NumberFieldEditPolicy = 'editable' | 'readonly' | 'detach-on-edit'
export type NumberFieldMutationSource = 'edit' | 'scrub' | 'step'
export interface NumberFieldRootProps {
modelValue: number | symbol
min?: number
max?: number
step?: number
sensitivity?: number
placeholder?: string
ariaLabel?: string
disabled?: boolean
bound?: boolean
editPolicy?: NumberFieldEditPolicy
}
export interface NumberFieldRootEmits {
'update:modelValue': [value: number]
commit: [value: number, previous: number]
'editing-change': [editing: boolean]
invalid: [expression: string, reason: NumberExpressionError]
'detach-request': [source: NumberFieldMutationSource]
}
export interface NumberFieldState {
editing: boolean
scrubbing: boolean
mixed: boolean
disabled: boolean
bound: boolean
}
export interface NumberFieldStateAttrs {
'data-editing'?: ''
'data-scrubbing'?: ''
'data-mixed'?: ''
'data-disabled'?: ''
'data-bound'?: ''
}
export interface NumberFieldRootAttrs extends NumberFieldStateAttrs {
role: 'spinbutton' | undefined
tabindex: 0 | -1 | undefined
'aria-valuenow'?: number
'aria-valuemin'?: number
'aria-valuemax'?: number
'aria-disabled'?: 'true'
'aria-label'?: string
onFocus: () => void
onKeydown: (event: KeyboardEvent) => void
}
export interface NumberFieldActions {
startScrub(event: PointerEvent): void
startEdit(): void
cancelEdit(): void
commitEdit(event?: Event): void
setDraft(value: string): void
input(event: Event): void
keydown(event: KeyboardEvent): void
}
export interface NumberFieldSlotProps extends NumberFieldState {
modelValue: number | symbol
displayValue: string
draftValue: string
isMixed: boolean
placeholder: string
state: NumberFieldState
attrs: NumberFieldRootAttrs
actions: NumberFieldActions
}
export interface NumberFieldRootSlots {
default(props: NumberFieldSlotProps): VNode[]
}
export interface NumberFieldValueSlots {
default(props: NumberFieldSlotProps & { value: string }): VNode[]
}
export interface NumberFieldContext {
modelValue: ComputedRef<number | symbol>
numericValue: ComputedRef<number>
displayValue: ComputedRef<string>
draftValue: Ref<string>
isMixed: ComputedRef<boolean>
editing: Ref<boolean>
scrubbing: Ref<boolean>
disabled: ComputedRef<boolean>
bound: ComputedRef<boolean>
min: ComputedRef<number>
max: ComputedRef<number>
step: ComputedRef<number>
inputRef: Ref<HTMLInputElement | null>
state: ComputedRef<NumberFieldState>
stateAttrs: ComputedRef<NumberFieldStateAttrs>
rootAttrs: ComputedRef<NumberFieldRootAttrs>
slotProps: ComputedRef<NumberFieldSlotProps>
actions: NumberFieldActions
invalidReason: Ref<NumberExpressionError | null>
}

View file

@ -1,17 +0,0 @@
<script setup lang="ts">
import { useScrubInput } from '#vue/primitives/ScrubInput/context'
const ctx = useScrubInput()
</script>
<template>
<span v-if="!ctx.editing.value" v-bind="$attrs">
<slot :value="ctx.displayValue.value" :is-mixed="ctx.isMixed.value" :placeholder="''">
{{ ctx.isMixed.value ? '' : ctx.displayValue.value }}
</slot>
</span>
</template>
<script lang="ts">
export default { inheritAttrs: false }
</script>

View file

@ -1,30 +0,0 @@
<script setup lang="ts">
import { templateRef } from '@vueuse/core'
import { watchEffect } from 'vue'
import { useScrubInput } from '#vue/primitives/ScrubInput/context'
const ctx = useScrubInput()
const inputEl = templateRef<HTMLInputElement>('inputEl')
watchEffect(() => {
ctx.inputRef.value = inputEl.value
})
</script>
<template>
<input
v-if="ctx.editing.value"
ref="inputEl"
type="number"
:value="ctx.isMixed.value ? '' : ctx.displayValue.value"
v-bind="$attrs"
@blur="ctx.commitEdit"
@keydown="ctx.onKeydown"
@input="ctx.liveUpdate"
/>
</template>
<script lang="ts">
export default { inheritAttrs: false }
</script>

View file

@ -1,151 +0,0 @@
<script setup lang="ts">
import { ref, computed, toRef, watch } from 'vue'
import { useEventListener } from '@vueuse/core'
import { provideScrubInput } from '#vue/primitives/ScrubInput/context'
import { inputNumberValue } from '#vue/shared/dom-events'
const {
modelValue,
min = -Infinity,
max = Infinity,
step = 1,
sensitivity = 1,
placeholder = 'Mixed'
} = defineProps<{
modelValue: number | symbol
min?: number
max?: number
step?: number
sensitivity?: number
placeholder?: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: number]
commit: [value: number, previous: number]
'editing-change': [editing: boolean]
}>()
const editing = ref(false)
const scrubbing = ref(false)
const inputRef = ref<HTMLInputElement | null>(null)
const isMixed = computed(() => typeof modelValue === 'symbol')
const numericValue = computed(() => (isMixed.value ? 0 : (modelValue as number)))
const displayValue = computed(() => (isMixed.value ? '' : String(Math.round(numericValue.value))))
let stopMove: (() => void) | undefined
let stopUp: (() => void) | undefined
function startScrub(e: PointerEvent) {
e.preventDefault()
const startX = e.clientX
let lastX = startX
let accumulated = numericValue.value
const valueBeforeScrub = numericValue.value
let hasMoved = false
stopMove = useEventListener(document, 'pointermove', (ev: PointerEvent) => {
const dx = ev.clientX - lastX
lastX = ev.clientX
if (!hasMoved && Math.abs(ev.clientX - startX) > 2) {
hasMoved = true
scrubbing.value = true
document.body.style.cursor = 'ew-resize'
}
if (hasMoved) {
accumulated += dx * step * sensitivity
const clamped = Math.round(Math.min(max, Math.max(min, accumulated)))
if (clamped !== modelValue) emit('update:modelValue', clamped)
}
})
stopUp = useEventListener(document, 'pointerup', () => {
scrubbing.value = false
document.body.style.cursor = ''
stopMove?.()
stopUp?.()
if (hasMoved) {
if (typeof modelValue === 'number' && modelValue !== valueBeforeScrub) {
emit('commit', modelValue, valueBeforeScrub)
}
} else {
startEdit()
}
})
}
function startEdit() {
editing.value = true
requestAnimationFrame(() => {
const input = inputRef.value
if (input) {
input.focus()
input.select()
}
})
}
function commitEdit(e: Event) {
if (!editing.value) return
const val = inputNumberValue(e)
const previous = numericValue.value
editing.value = false
if (!Number.isNaN(val)) {
const clamped = Math.min(max, Math.max(min, val))
emit('update:modelValue', clamped)
if (clamped !== previous) emit('commit', clamped, previous)
}
}
function liveUpdate(e: Event) {
const val = inputNumberValue(e)
if (!Number.isNaN(val)) {
const clamped = Math.min(max, Math.max(min, val))
emit('update:modelValue', clamped)
}
}
function onKeydown(e: KeyboardEvent) {
if (e.code === 'Enter') commitEdit(e)
else if (e.code === 'Escape') editing.value = false
}
const ctx = {
modelValue: toRef(() => modelValue),
displayValue,
isMixed,
editing,
scrubbing,
inputRef,
startScrub,
startEdit,
liveUpdate,
commitEdit,
onKeydown
}
const actions = {
startScrub,
startEdit,
commitEdit,
keydown: onKeydown
}
provideScrubInput(ctx)
watch(editing, (v) => emit('editing-change', v))
</script>
<template>
<slot
:model-value="modelValue"
:display-value="displayValue"
:is-mixed="isMixed"
:editing="editing"
:scrubbing="scrubbing"
:actions="actions"
:placeholder="placeholder"
/>
</template>

View file

@ -1,27 +0,0 @@
import { type InjectionKey, type Ref, inject, provide } from 'vue'
export interface ScrubInputContext {
modelValue: Ref<number | symbol>
displayValue: Ref<string>
isMixed: Ref<boolean>
editing: Ref<boolean>
scrubbing: Ref<boolean>
inputRef: Ref<HTMLInputElement | null>
startScrub: (e: PointerEvent) => void
startEdit: () => void
liveUpdate: (e: Event) => void
commitEdit: (e: Event) => void
onKeydown: (e: KeyboardEvent) => void
}
export const SCRUB_INPUT_KEY: InjectionKey<ScrubInputContext> = Symbol('scrub-input')
export function provideScrubInput(ctx: ScrubInputContext) {
provide(SCRUB_INPUT_KEY, ctx)
}
export function useScrubInput(): ScrubInputContext {
const ctx = inject(SCRUB_INPUT_KEY)
if (!ctx) throw new Error('[open-pencil] useScrubInput() called outside <ScrubInputRoot>')
return ctx
}

View file

@ -1,5 +1,10 @@
export { default as ScrubInputRoot } from '#vue/primitives/ScrubInput/ScrubInputRoot.vue'
export { default as ScrubInputField } from '#vue/primitives/ScrubInput/ScrubInputField.vue'
export { default as ScrubInputDisplay } from '#vue/primitives/ScrubInput/ScrubInputDisplay.vue'
export { useScrubInput } from '#vue/primitives/ScrubInput/context'
export type { ScrubInputContext } from '#vue/primitives/ScrubInput/context'
/** @deprecated Use NumberFieldRoot. */
export { default as ScrubInputRoot } from '#vue/primitives/NumberField/NumberFieldRoot.vue'
/** @deprecated Use NumberFieldInput. */
export { default as ScrubInputField } from '#vue/primitives/NumberField/NumberFieldInput.vue'
/** @deprecated Use NumberFieldValue. */
export { default as ScrubInputDisplay } from '#vue/primitives/NumberField/NumberFieldValue.vue'
/** @deprecated Use useNumberField. */
export { useNumberField as useScrubInput } from '#vue/primitives/NumberField/context'
/** @deprecated Use NumberFieldContext. */
export type { NumberFieldContext as ScrubInputContext } from '#vue/primitives/NumberField/types'

View file

@ -1,4 +1,6 @@
<script lang="ts">
import type { NumberExpressionError, NumberFieldEditPolicy } from '@open-pencil/vue'
import type { ComponentUI } from '@/components/ui/types'
import type { ScrubInputTheme } from '@/theme/scrub-input'
@ -14,6 +16,9 @@ export interface ScrubInputProps {
suffix?: string
sensitivity?: number
placeholder?: string
disabled?: boolean
bound?: boolean
editPolicy?: NumberFieldEditPolicy
ui?: ScrubInputUI
}
</script>
@ -31,14 +36,33 @@ const store = useEditorStore()
const rootTestId = computed(() => (attrs['data-test-id'] as string | undefined) ?? 'scrub-input')
const { modelValue, min, max, step, icon, label, suffix, sensitivity, placeholder, ui } =
defineProps<ScrubInputProps>()
const {
modelValue,
min,
max,
step,
icon,
label,
suffix,
sensitivity,
placeholder,
disabled,
bound,
editPolicy,
ui
} = defineProps<ScrubInputProps>()
const accessibleLabel = computed(() => {
const ariaLabel = attrs['aria-label']
return typeof ariaLabel === 'string' ? ariaLabel : (label ?? icon)
})
const styles = computed(() => tv(theme)({ suffix: Boolean(slots.suffix) }))
const emit = defineEmits<{
'update:modelValue': [value: number]
'editing-change': [editing: boolean]
commit: [value: number, previous: number]
invalid: [expression: string, reason: NumberExpressionError]
'detach-request': [source: 'edit' | 'scrub' | 'step']
}>()
defineOptions({ inheritAttrs: false })
@ -46,15 +70,23 @@ defineOptions({ inheritAttrs: false })
<template>
<ScrubInputRoot
v-slot="{ editing, actions, placeholder: ph }"
v-slot="{ editing, disabled: isDisabled, actions, attrs: rootAttrs, placeholder: ph }"
:model-value="modelValue"
:min="min"
:max="max"
:step="step"
:sensitivity="sensitivity"
:placeholder="placeholder"
:aria-label="accessibleLabel"
:disabled="disabled"
:bound="bound"
:edit-policy="editPolicy"
@update:model-value="emit('update:modelValue', $event)"
@commit="(val: number, prev: number) => emit('commit', val, prev)"
@invalid="
(expression: string, reason: NumberExpressionError) => emit('invalid', expression, reason)
"
@detach-request="emit('detach-request', $event)"
@editing-change="
(editing: boolean) => {
store.state.scrubInputFocused = editing
@ -63,16 +95,14 @@ defineOptions({ inheritAttrs: false })
"
>
<div
v-bind="{ ...attrs, ...testId(rootTestId) }"
:tabindex="editing ? undefined : 0"
v-bind="{ ...attrs, ...rootAttrs, ...testId(rootTestId) }"
:class="styles.root({ class: [ui?.root, normalizeClass(attrs.class)] })"
:style="{ cursor: editing ? 'auto' : 'ew-resize' }"
:style="{ cursor: editing || isDisabled ? 'auto' : 'ew-resize' }"
@pointerdown="
!editing &&
!($event.target as HTMLElement)?.closest?.('button') &&
actions.startScrub($event)
"
@focus="!editing && actions.startEdit()"
>
<span v-if="attrs['data-test-id']" data-test-id="scrub-input" class="hidden" />
<span :class="styles.leading({ class: ui?.leading })">
@ -84,9 +114,6 @@ defineOptions({ inheritAttrs: false })
<ScrubInputField
data-test-id="scrub-input-field"
:class="styles.field({ class: ui?.field })"
:min="min === -Infinity ? undefined : min"
:max="max === Infinity ? undefined : max"
:step="step"
/>
<slot v-if="editing" name="suffix" />
<ScrubInputDisplay :class="styles.display({ class: ui?.display })">

View file

@ -0,0 +1,91 @@
import { expect, test, useEditorSetup } from '#tests/e2e/fixtures'
import { expectDefined } from '#tests/helpers/assert'
import { getSelectedNode } from '#tests/helpers/store'
const editor = useEditorSetup()
function xField() {
return editor.page.getByTestId('position-section').getByTestId('scrub-input').first()
}
async function editField(field: ReturnType<typeof xField>) {
await field.click()
return field.getByTestId('scrub-input-field')
}
async function numericFieldValue(field: ReturnType<typeof xField>): Promise<number> {
return Number(expectDefined(await field.getAttribute('aria-valuenow'), 'field value'))
}
test.beforeEach(async () => {
await editor.canvas.clearCanvas()
await editor.canvas.drawRect(120, 100, 80, 80)
})
test('NumberField commits arithmetic and relative expressions', async () => {
const field = xField()
const before = await numericFieldValue(field)
let input = await editField(field)
await expect(field).not.toHaveAttribute('role')
await expect(input).toHaveAttribute('role', 'spinbutton')
await expect(field.getByRole('spinbutton')).toHaveCount(1)
await input.fill('*2')
await input.press('Enter')
await editor.canvas.waitForRender()
expect((await getSelectedNode(editor.page))?.x).toBe(before * 2)
input = await editField(field)
await input.fill('+10')
await input.press('Enter')
await editor.canvas.waitForRender()
expect((await getSelectedNode(editor.page))?.x).toBe(before * 2 + 10)
await expect(field).toHaveAttribute('aria-valuenow', String(before * 2 + 10))
editor.canvas.assertNoErrors()
})
test('NumberField invalid input and Escape restore the previous value', async () => {
const field = xField()
const before = await numericFieldValue(field)
let input = await editField(field)
await input.fill('77')
await editor.canvas.waitForRender()
expect((await getSelectedNode(editor.page))?.x).toBe(77)
await input.press('Escape')
await editor.canvas.waitForRender()
expect((await getSelectedNode(editor.page))?.x).toBe(before)
input = await editField(field)
await input.fill('constructor')
await input.press('Enter')
await editor.canvas.waitForRender()
expect((await getSelectedNode(editor.page))?.x).toBe(before)
await expect(field).not.toHaveAttribute('data-editing')
editor.canvas.assertNoErrors()
})
test('NumberField Arrow keys honor Shift and Alt multipliers', async () => {
const field = xField()
const before = await numericFieldValue(field)
const input = await editField(field)
await input.press('ArrowUp')
await input.press('Shift+ArrowUp')
await input.press('Alt+ArrowDown')
await input.press('Enter')
await editor.canvas.waitForRender()
expect((await getSelectedNode(editor.page))?.x).toBe(before + 10.9)
editor.canvas.assertNoErrors()
})
test('NumberField exposes mixed state through canonical data attributes', async () => {
await editor.canvas.drawRect(300, 100, 80, 80)
await editor.canvas.selectAll()
await editor.canvas.waitForRender()
await expect(xField()).toHaveAttribute('data-mixed')
await expect(xField()).not.toHaveAttribute('aria-valuenow')
editor.canvas.assertNoErrors()
})

View file

@ -0,0 +1,114 @@
import { describe, expect, test } from 'bun:test'
import {
clampNumberValue,
evaluateNumberExpression,
normalizeNumberValue,
stepNumberValue
} from '#vue/controls/number-expression'
function value(expression: string, current = 20, max = Infinity): number {
const result = evaluateNumberExpression(expression, { current, max })
if (!result.ok) throw new Error(`Expected expression to succeed: ${result.error}`)
return result.value
}
describe('evaluateNumberExpression', () => {
test('evaluates absolute numbers and arithmetic with precedence', () => {
expect(value('42')).toBe(42)
expect(value('12.5')).toBe(12.5)
expect(value('12*8+4')).toBe(100)
expect(value('(12 + 8) * 4')).toBe(80)
expect(value('1e3 / 4')).toBe(250)
})
test('supports unary operators inside absolute expressions', () => {
expect(value('(-4)')).toBe(-4)
expect(value('2 * -(3 + 1)')).toBe(-8)
})
test('evaluates leading operators relative to the current value', () => {
expect(value('+10')).toBe(30)
expect(value('-4')).toBe(16)
expect(value('*2')).toBe(40)
expect(value('/4')).toBe(5)
})
test('evaluates percentages against a finite maximum', () => {
const result = evaluateNumberExpression('50%', { current: 20, max: 240 })
expect(result).toEqual({ ok: true, value: 120, kind: 'percent' })
expect(value('(25 + 25)%', 20, 80)).toBe(40)
})
test('rejects percentages without a finite maximum', () => {
expect(evaluateNumberExpression('50%', { current: 20 })).toEqual({
ok: false,
error: 'percent-requires-finite-max'
})
})
test('rejects relative expressions for mixed values', () => {
expect(evaluateNumberExpression('+10', { current: 0, mixed: true })).toEqual({
ok: false,
error: 'relative-requires-value'
})
expect(evaluateNumberExpression('42', { current: 0, mixed: true })).toEqual({
ok: true,
value: 42,
kind: 'absolute'
})
})
test('rejects empty, malformed, and non-finite expressions', () => {
expect(evaluateNumberExpression(' ', { current: 20 })).toEqual({
ok: false,
error: 'empty'
})
expect(evaluateNumberExpression('12 +', { current: 20 })).toEqual({
ok: false,
error: 'syntax'
})
expect(evaluateNumberExpression('1 / 0', { current: 20 })).toEqual({
ok: false,
error: 'non-finite'
})
})
test('rejects identifiers, calls, members, assignments, and arrays', () => {
for (const expression of [
'sqrt(9)',
'constructor',
'Object.assign(1)',
'__proto__.polluted',
'x = 2',
'[1, 2]',
'1; 2',
'2 ** 4'
]) {
expect(evaluateNumberExpression(expression, { current: 20 })).toEqual({
ok: false,
error: 'syntax'
})
}
})
})
describe('number value helpers', () => {
test('clamps values to finite or one-sided ranges', () => {
expect(clampNumberValue(12, 0, 10)).toBe(10)
expect(clampNumberValue(-2, 0)).toBe(0)
expect(clampNumberValue(4, -Infinity, 10)).toBe(4)
})
test('normalizes floating-point noise', () => {
expect(normalizeNumberValue(0.1 + 0.2)).toBe(0.3)
})
test('steps with keyboard modifiers and clamps', () => {
expect(stepNumberValue(10, 1, 2)).toBe(12)
expect(stepNumberValue(10, -1, 2, { shiftKey: true })).toBe(-10)
expect(stepNumberValue(10, 1, 2, { altKey: true })).toBe(10.2)
expect(stepNumberValue(10, 1, 2, { shiftKey: true, altKey: true })).toBe(12)
expect(stepNumberValue(9, 1, 2, {}, 0, 10)).toBe(10)
})
})