From e8092bd5610bd248933f4b2fdf2ce4981cc4cb76 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 10 Jul 2026 22:26:51 +0300 Subject: [PATCH] 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 --- .storybook/main.ts | 2 +- AGENTS.md | 2 +- CHANGELOG.md | 1 + .../programmable/sdk/api/advanced/index.md | 3 +- .../sdk/api/advanced/use-number-field.md | 22 ++ .../sdk/api/advanced/use-scrub-input.md | 15 +- .../programmable/sdk/api/components/index.md | 7 +- .../sdk/api/components/number-field.md | 104 +++++ .../sdk/api/components/scrub-input-display.md | 33 +- .../sdk/api/components/scrub-input-field.md | 33 +- .../sdk/api/components/scrub-input-root.md | 72 +--- packages/docs/programmable/sdk/api/index.md | 2 +- .../docs/programmable/sdk/architecture.md | 2 +- packages/vue/README.md | 16 +- .../controls/number-expression/evaluate.ts | 143 +++++++ .../src/controls/number-expression/index.ts | 11 + packages/vue/src/index.ts | 35 ++ .../NumberField/NumberField.stories.ts | 150 ++++++++ .../NumberField/NumberFieldInput.vue | 42 ++ .../NumberField/NumberFieldRoot.vue | 358 ++++++++++++++++++ .../NumberField/NumberFieldValue.vue | 16 + .../vue/src/primitives/NumberField/context.ts | 4 + .../vue/src/primitives/NumberField/index.ts | 24 ++ .../vue/src/primitives/NumberField/parts.ts | 26 ++ .../vue/src/primitives/NumberField/types.ts | 106 ++++++ .../ScrubInput/ScrubInputDisplay.vue | 17 - .../primitives/ScrubInput/ScrubInputField.vue | 30 -- .../primitives/ScrubInput/ScrubInputRoot.vue | 151 -------- .../vue/src/primitives/ScrubInput/context.ts | 27 -- .../vue/src/primitives/ScrubInput/index.ts | 15 +- src/components/inputs/ScrubInput.vue | 47 ++- tests/e2e/properties/number-field.spec.ts | 91 +++++ .../vue/controls/number-expression.test.ts | 114 ++++++ 33 files changed, 1352 insertions(+), 369 deletions(-) create mode 100644 packages/docs/programmable/sdk/api/advanced/use-number-field.md create mode 100644 packages/docs/programmable/sdk/api/components/number-field.md create mode 100644 packages/vue/src/controls/number-expression/evaluate.ts create mode 100644 packages/vue/src/controls/number-expression/index.ts create mode 100644 packages/vue/src/primitives/NumberField/NumberField.stories.ts create mode 100644 packages/vue/src/primitives/NumberField/NumberFieldInput.vue create mode 100644 packages/vue/src/primitives/NumberField/NumberFieldRoot.vue create mode 100644 packages/vue/src/primitives/NumberField/NumberFieldValue.vue create mode 100644 packages/vue/src/primitives/NumberField/context.ts create mode 100644 packages/vue/src/primitives/NumberField/index.ts create mode 100644 packages/vue/src/primitives/NumberField/parts.ts create mode 100644 packages/vue/src/primitives/NumberField/types.ts delete mode 100644 packages/vue/src/primitives/ScrubInput/ScrubInputDisplay.vue delete mode 100644 packages/vue/src/primitives/ScrubInput/ScrubInputField.vue delete mode 100644 packages/vue/src/primitives/ScrubInput/ScrubInputRoot.vue delete mode 100644 packages/vue/src/primitives/ScrubInput/context.ts create mode 100644 tests/e2e/properties/number-field.spec.ts create mode 100644 tests/engine/vue/controls/number-expression.test.ts diff --git a/.storybook/main.ts b/.storybook/main.ts index 5d26097d0..2f8c48890 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -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', diff --git a/AGENTS.md b/AGENTS.md index 86870ce41..32e61672d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 (``) — 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index c3de86a97..13aa538bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/packages/docs/programmable/sdk/api/advanced/index.md b/packages/docs/programmable/sdk/api/advanced/index.md index ed0459183..2b04eb05b 100644 --- a/packages/docs/programmable/sdk/api/advanced/index.md +++ b/packages/docs/programmable/sdk/api/advanced/index.md @@ -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) diff --git a/packages/docs/programmable/sdk/api/advanced/use-number-field.md b/packages/docs/programmable/sdk/api/advanced/use-number-field.md new file mode 100644 index 000000000..91570ba7d --- /dev/null +++ b/packages/docs/programmable/sdk/api/advanced/use-number-field.md @@ -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. diff --git a/packages/docs/programmable/sdk/api/advanced/use-scrub-input.md b/packages/docs/programmable/sdk/api/advanced/use-scrub-input.md index addbafec0..1f39a6767 100644 --- a/packages/docs/programmable/sdk/api/advanced/use-scrub-input.md +++ b/packages/docs/programmable/sdk/api/advanced/use-scrub-input.md @@ -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. diff --git a/packages/docs/programmable/sdk/api/components/index.md b/packages/docs/programmable/sdk/api/components/index.md index be6e15907..89e04f0fc 100644 --- a/packages/docs/programmable/sdk/api/components/index.md +++ b/packages/docs/programmable/sdk/api/components/index.md @@ -40,7 +40,8 @@ description: Component reference for headless Vue primitives in @open-pencil/vue - - - + + + + diff --git a/packages/docs/programmable/sdk/api/components/number-field.md b/packages/docs/programmable/sdk/api/components/number-field.md new file mode 100644 index 000000000..a54abfe65 --- /dev/null +++ b/packages/docs/programmable/sdk/api/components/number-field.md @@ -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 + + + +## Events + + + +## 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 + + + +``` + +## Compatibility + +`ScrubInputRoot`, `ScrubInputField`, `ScrubInputDisplay`, and `useScrubInput()` are deprecated +aliases. They use the NumberField implementation and can be migrated without changing model or +commit semantics. diff --git a/packages/docs/programmable/sdk/api/components/scrub-input-display.md b/packages/docs/programmable/sdk/api/components/scrub-input-display.md index 5fb005fd1..2fe0e052f 100644 --- a/packages/docs/programmable/sdk/api/components/scrub-input-display.md +++ b/packages/docs/programmable/sdk/api/components/scrub-input-display.md @@ -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 - - - Passed through to the rendered span element. - - -## Example - -```vue - - - -``` - -## 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. diff --git a/packages/docs/programmable/sdk/api/components/scrub-input-field.md b/packages/docs/programmable/sdk/api/components/scrub-input-field.md index 6ef2d4c0d..1d001b4de 100644 --- a/packages/docs/programmable/sdk/api/components/scrub-input-field.md +++ b/packages/docs/programmable/sdk/api/components/scrub-input-field.md @@ -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 - - - Passed through to the rendered input element. - - -## Example - -```vue - - - -``` - -## 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. diff --git a/packages/docs/programmable/sdk/api/components/scrub-input-root.md b/packages/docs/programmable/sdk/api/components/scrub-input-root.md index 856ffb1cc..40b976957 100644 --- a/packages/docs/programmable/sdk/api/components/scrub-input-root.md +++ b/packages/docs/programmable/sdk/api/components/scrub-input-root.md @@ -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 - - - -## Model - - - -## Events - - - -## Slots - - - -## Example - -```vue - -
- - -
-
+```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. diff --git a/packages/docs/programmable/sdk/api/index.md b/packages/docs/programmable/sdk/api/index.md index 8859cb675..25d9f3bb8 100644 --- a/packages/docs/programmable/sdk/api/index.md +++ b/packages/docs/programmable/sdk/api/index.md @@ -8,7 +8,7 @@ description: Reference documentation for @open-pencil/vue components, composable The Vue SDK reference is organized into three sections. - + diff --git a/packages/docs/programmable/sdk/architecture.md b/packages/docs/programmable/sdk/architecture.md index 608b6817a..9e2538ae1 100644 --- a/packages/docs/programmable/sdk/architecture.md +++ b/packages/docs/programmable/sdk/architecture.md @@ -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. diff --git a/packages/vue/README.md b/packages/vue/README.md index ff9aa509c..b2d583f29 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -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()` diff --git a/packages/vue/src/controls/number-expression/evaluate.ts b/packages/vue/src/controls/number-expression/evaluate.ts new file mode 100644 index 000000000..6b0ba33d5 --- /dev/null +++ b/packages/vue/src/controls/number-expression/evaluate.ts @@ -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) +} diff --git a/packages/vue/src/controls/number-expression/index.ts b/packages/vue/src/controls/number-expression/index.ts new file mode 100644 index 000000000..498b5be7e --- /dev/null +++ b/packages/vue/src/controls/number-expression/index.ts @@ -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' diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index 447ae4ae9..c1273c8d7 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -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, diff --git a/packages/vue/src/primitives/NumberField/NumberField.stories.ts b/packages/vue/src/primitives/NumberField/NumberField.stories.ts new file mode 100644 index 000000000..d0e6d0e7b --- /dev/null +++ b/packages/vue/src/primitives/NumberField/NumberField.stories.ts @@ -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 + +function renderStateMatrix() { + return { + components: { + NumberFieldInput, + NumberFieldLeading, + NumberFieldMenu, + NumberFieldRoot, + NumberFieldTrailing, + NumberFieldUnit, + NumberFieldValue + }, + setup() { + const value = ref(24) + const mixed = ref(MIXED) + const disabled = ref(16) + const bound = ref(8) + return { value, mixed, disabled, bound } + }, + template: ` +
+
+

Interactive anatomy

+

Try +10, *2, 50%, or 12*8+4

+ +
+ W + + + px + + R + + + M + +
+
+
+ +
+
+

Mixed

+ +
+ + +
+
+
+ +
+

Disabled

+ +
+ +
+
+
+ +
+

Bound

+ +
+ + + gap/md +
+
+
+
+
+ ` + } +} + +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( + '[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() + } +} diff --git a/packages/vue/src/primitives/NumberField/NumberFieldInput.vue b/packages/vue/src/primitives/NumberField/NumberFieldInput.vue new file mode 100644 index 000000000..52960cbec --- /dev/null +++ b/packages/vue/src/primitives/NumberField/NumberFieldInput.vue @@ -0,0 +1,42 @@ + + + diff --git a/packages/vue/src/primitives/NumberField/NumberFieldRoot.vue b/packages/vue/src/primitives/NumberField/NumberFieldRoot.vue new file mode 100644 index 000000000..70a8d2bf8 --- /dev/null +++ b/packages/vue/src/primitives/NumberField/NumberFieldRoot.vue @@ -0,0 +1,358 @@ + + + diff --git a/packages/vue/src/primitives/NumberField/NumberFieldValue.vue b/packages/vue/src/primitives/NumberField/NumberFieldValue.vue new file mode 100644 index 000000000..1ca3d1fb3 --- /dev/null +++ b/packages/vue/src/primitives/NumberField/NumberFieldValue.vue @@ -0,0 +1,16 @@ + + + diff --git a/packages/vue/src/primitives/NumberField/context.ts b/packages/vue/src/primitives/NumberField/context.ts new file mode 100644 index 000000000..da458fa4f --- /dev/null +++ b/packages/vue/src/primitives/NumberField/context.ts @@ -0,0 +1,4 @@ +import { createContext } from '#vue/internal/create-context' +import type { NumberFieldContext } from '#vue/primitives/NumberField/types' + +export const [useNumberField, provideNumberField] = createContext('NumberField') diff --git a/packages/vue/src/primitives/NumberField/index.ts b/packages/vue/src/primitives/NumberField/index.ts new file mode 100644 index 000000000..7f6f20904 --- /dev/null +++ b/packages/vue/src/primitives/NumberField/index.ts @@ -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' diff --git a/packages/vue/src/primitives/NumberField/parts.ts b/packages/vue/src/primitives/NumberField/parts.ts new file mode 100644 index 000000000..c5a5b9fe0 --- /dev/null +++ b/packages/vue/src/primitives/NumberField/parts.ts @@ -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') diff --git a/packages/vue/src/primitives/NumberField/types.ts b/packages/vue/src/primitives/NumberField/types.ts new file mode 100644 index 000000000..8fbd6a25f --- /dev/null +++ b/packages/vue/src/primitives/NumberField/types.ts @@ -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 + numericValue: ComputedRef + displayValue: ComputedRef + draftValue: Ref + isMixed: ComputedRef + editing: Ref + scrubbing: Ref + disabled: ComputedRef + bound: ComputedRef + min: ComputedRef + max: ComputedRef + step: ComputedRef + inputRef: Ref + state: ComputedRef + stateAttrs: ComputedRef + rootAttrs: ComputedRef + slotProps: ComputedRef + actions: NumberFieldActions + invalidReason: Ref +} diff --git a/packages/vue/src/primitives/ScrubInput/ScrubInputDisplay.vue b/packages/vue/src/primitives/ScrubInput/ScrubInputDisplay.vue deleted file mode 100644 index 02097bf69..000000000 --- a/packages/vue/src/primitives/ScrubInput/ScrubInputDisplay.vue +++ /dev/null @@ -1,17 +0,0 @@ - - - - - diff --git a/packages/vue/src/primitives/ScrubInput/ScrubInputField.vue b/packages/vue/src/primitives/ScrubInput/ScrubInputField.vue deleted file mode 100644 index 5a1e8ad2b..000000000 --- a/packages/vue/src/primitives/ScrubInput/ScrubInputField.vue +++ /dev/null @@ -1,30 +0,0 @@ - - - - - diff --git a/packages/vue/src/primitives/ScrubInput/ScrubInputRoot.vue b/packages/vue/src/primitives/ScrubInput/ScrubInputRoot.vue deleted file mode 100644 index 8d620d54b..000000000 --- a/packages/vue/src/primitives/ScrubInput/ScrubInputRoot.vue +++ /dev/null @@ -1,151 +0,0 @@ - - - diff --git a/packages/vue/src/primitives/ScrubInput/context.ts b/packages/vue/src/primitives/ScrubInput/context.ts deleted file mode 100644 index 5d1aff0c8..000000000 --- a/packages/vue/src/primitives/ScrubInput/context.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { type InjectionKey, type Ref, inject, provide } from 'vue' - -export interface ScrubInputContext { - modelValue: Ref - displayValue: Ref - isMixed: Ref - editing: Ref - scrubbing: Ref - inputRef: Ref - startScrub: (e: PointerEvent) => void - startEdit: () => void - liveUpdate: (e: Event) => void - commitEdit: (e: Event) => void - onKeydown: (e: KeyboardEvent) => void -} - -export const SCRUB_INPUT_KEY: InjectionKey = 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 ') - return ctx -} diff --git a/packages/vue/src/primitives/ScrubInput/index.ts b/packages/vue/src/primitives/ScrubInput/index.ts index e9db2c06b..5789ed744 100644 --- a/packages/vue/src/primitives/ScrubInput/index.ts +++ b/packages/vue/src/primitives/ScrubInput/index.ts @@ -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' diff --git a/src/components/inputs/ScrubInput.vue b/src/components/inputs/ScrubInput.vue index cf5928d94..19249fca3 100644 --- a/src/components/inputs/ScrubInput.vue +++ b/src/components/inputs/ScrubInput.vue @@ -1,4 +1,6 @@ @@ -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() +const { + modelValue, + min, + max, + step, + icon, + label, + suffix, + sensitivity, + placeholder, + disabled, + bound, + editPolicy, + ui +} = defineProps() +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 })