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:
parent
f525c0f4fb
commit
e8092bd561
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
104
packages/docs/programmable/sdk/api/components/number-field.md
Normal file
104
packages/docs/programmable/sdk/api/components/number-field.md
Normal 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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()`
|
||||
|
|
|
|||
143
packages/vue/src/controls/number-expression/evaluate.ts
Normal file
143
packages/vue/src/controls/number-expression/evaluate.ts
Normal 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)
|
||||
}
|
||||
11
packages/vue/src/controls/number-expression/index.ts
Normal file
11
packages/vue/src/controls/number-expression/index.ts
Normal 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'
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
150
packages/vue/src/primitives/NumberField/NumberField.stories.ts
Normal file
150
packages/vue/src/primitives/NumberField/NumberField.stories.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
42
packages/vue/src/primitives/NumberField/NumberFieldInput.vue
Normal file
42
packages/vue/src/primitives/NumberField/NumberFieldInput.vue
Normal 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>
|
||||
358
packages/vue/src/primitives/NumberField/NumberFieldRoot.vue
Normal file
358
packages/vue/src/primitives/NumberField/NumberFieldRoot.vue
Normal 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>
|
||||
16
packages/vue/src/primitives/NumberField/NumberFieldValue.vue
Normal file
16
packages/vue/src/primitives/NumberField/NumberFieldValue.vue
Normal 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>
|
||||
4
packages/vue/src/primitives/NumberField/context.ts
Normal file
4
packages/vue/src/primitives/NumberField/context.ts
Normal 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')
|
||||
24
packages/vue/src/primitives/NumberField/index.ts
Normal file
24
packages/vue/src/primitives/NumberField/index.ts
Normal 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'
|
||||
26
packages/vue/src/primitives/NumberField/parts.ts
Normal file
26
packages/vue/src/primitives/NumberField/parts.ts
Normal 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')
|
||||
106
packages/vue/src/primitives/NumberField/types.ts
Normal file
106
packages/vue/src/primitives/NumberField/types.ts
Normal 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>
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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 })">
|
||||
|
|
|
|||
91
tests/e2e/properties/number-field.spec.ts
Normal file
91
tests/e2e/properties/number-field.spec.ts
Normal 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()
|
||||
})
|
||||
114
tests/engine/vue/controls/number-expression.test.ts
Normal file
114
tests/engine/vue/controls/number-expression.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue