diff --git a/packages/frontend/@n8n/design-system/src/components/N8nInputNumber/InputNumber.stories.ts b/packages/frontend/@n8n/design-system/src/components/N8nInputNumber/InputNumber.stories.ts index 656321db00c..6be783a6c36 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nInputNumber/InputNumber.stories.ts +++ b/packages/frontend/@n8n/design-system/src/components/N8nInputNumber/InputNumber.stories.ts @@ -1,124 +1,421 @@ -import type { StoryFn } from '@storybook/vue3-vite'; +import type { Meta, StoryObj } from '@storybook/vue3-vite'; import { action } from 'storybook/actions'; +import { defineComponent, reactive, ref } from 'vue'; -import N8nInputNumber from './InputNumber.vue'; +import InputNumber from './InputNumber.vue'; +import N8nIcon from '../N8nIcon/Icon.vue'; +import N8nInputLabel from '../N8nInputLabel/InputLabel.vue'; +import N8nText from '../N8nText/Text.vue'; -export default { +const meta = { title: 'Core/Input Number', - component: N8nInputNumber, - argTypes: { - placeholder: { - control: 'text', - }, - disabled: { - control: { - type: 'boolean', - }, - }, - controls: { - control: { - type: 'boolean', - }, - }, - precision: { - control: { - type: 'number', - }, - }, - min: { - control: { - type: 'number', - }, - }, - max: { - control: { - type: 'number', - }, - }, - step: { - control: { - type: 'number', - }, - }, - title: { - control: 'text', - }, - }, + component: InputNumber, parameters: { docs: { description: { - component: 'A numeric input control for entering and stepping through numbers.', + component: + 'Number input built on Reka UI NumberField. Visual affordances match `N8nInput` (shared input size/theme tokens, inset border, focus ring).', }, + source: { type: 'dynamic' }, }, - backgrounds: { default: '--color--background--light-2' }, }, -}; + argTypes: { + modelValue: { control: 'number' }, + defaultValue: { + control: 'number', + description: 'Initial value when used without v-model (uncontrolled)', + }, + size: { + control: 'select', + options: ['mini', 'small', 'medium', 'large', 'xlarge'], + }, + min: { control: 'number' }, + max: { control: 'number' }, + step: { control: 'number' }, + precision: { control: 'number' }, + controls: { control: 'boolean' }, + controlsPosition: { + control: 'select', + options: ['both', 'right'], + }, + disabled: { control: 'boolean' }, + placeholder: { control: 'text' }, + }, + args: { + modelValue: 0, + size: 'medium', + controls: true, + controlsPosition: 'right', + disabled: false, + placeholder: 'Enter a number', + }, +} satisfies Meta; -const methods = { - onUpdateModelValue: action('update:modelValue'), -}; +export default meta; -const Template: StoryFn = (args, { argTypes }) => ({ - setup: () => ({ args }), - props: Object.keys(argTypes), - components: { - N8nInputNumber, +type Story = StoryObj; + +const storyPadding = 'padding: var(--spacing--xl);'; +const storyStack = `${storyPadding} display: flex; flex-direction: column; gap: var(--spacing--md); max-width: 320px;`; + +export const Default = { + render: (args) => ({ + components: { InputNumber }, + setup() { + const value = ref(args.modelValue); + return { args, value }; + }, + template: ` +
+ +
+ `, + }), +} satisfies Story; + +const InputNumberControlledUncontrolledDemo = defineComponent({ + name: 'InputNumberControlledUncontrolledDemo', + components: { InputNumber, N8nInputLabel, N8nText }, + setup() { + const value = ref(5); + return { value, onUpdate: action('update:modelValue') }; }, - template: - '', - data() { - return { - val: null, - }; - }, - methods, + template: ` +
+
+ + + Parent-controlled value via v-model. Use the buttons below to set the value externally. + + +
+ + + +
+ Selected: {{ value }} +
+
+ + + Initial value set with defaultValue="3". The parent does not track changes. + + +
+
+ `, }); -export const Input = Template.bind({}); -Input.args = { - placeholder: 'placeholder...', - controls: false, +export const ControlledUncontrolled: Story = { + name: 'Controlled/Uncontrolled', + render: () => ({ + components: { InputNumberControlledUncontrolledDemo }, + template: '', + }), }; -const ManyTemplate: StoryFn = (args, { argTypes }) => ({ - setup: () => ({ args }), - props: Object.keys(argTypes), - components: { - N8nInputNumber, +export const WithControlsBoth = { + render: (args) => ({ + components: { InputNumber }, + setup() { + const value = ref(args.modelValue); + return { args, value }; + }, + template: ` +
+ +
+ `, + }), + args: { + modelValue: 5, + min: 0, + max: 10, + controls: true, + controlsPosition: 'both', }, - template: - '
', - methods, - data() { - return { - val: '', - }; - }, -}); +} satisfies Story; -export const Sizes = ManyTemplate.bind({}); -Sizes.args = { - placeholder: 'placeholder...', - controls: false, -}; - -const ControlsTemplate: StoryFn = (args, { argTypes }) => ({ - setup: () => ({ args }), - props: Object.keys(argTypes), - components: { - N8nInputNumber, +export const NoControls = { + render: (args) => ({ + components: { InputNumber }, + setup() { + const value = ref(args.modelValue); + return { args, value }; + }, + template: ` +
+ +
+ `, + }), + args: { + modelValue: 5, + min: 0, + max: 10, + controls: false, }, - template: - '
', - methods, - data() { - return { - val: '', - }; - }, -}); +} satisfies Story; -export const Controls = ControlsTemplate.bind({}); -Controls.args = { - placeholder: 'placeholder...', -}; +export const Sizes = { + render: () => ({ + components: { InputNumber, N8nInputLabel }, + setup() { + const withoutControls = reactive({ + mini: 42, + small: 42, + medium: 42, + large: 42, + xlarge: 42, + }); + const controlsBoth = reactive({ + mini: 42, + small: 42, + medium: 42, + large: 42, + xlarge: 42, + }); + const controlsRight = reactive({ + mini: 42, + small: 42, + medium: 42, + large: 42, + xlarge: 42, + }); + return { withoutControls, controlsBoth, controlsRight }; + }, + template: ` +
+
+ + + + + + +
+
+ + + + + + +
+
+ + + + + + +
+
+ `, + }), +} satisfies Story; + +export const Precision = { + render: () => ({ + components: { InputNumber, N8nInputLabel }, + setup() { + const withoutControls = reactive({ + none: 3.14159, + two: 3.14, + one: 3.1, + zero: 3, + }); + const controlsBoth = reactive({ + none: 3.14159, + two: 3.14, + one: 3.1, + zero: 3, + }); + const controlsRight = reactive({ + none: 3.14159, + two: 3.14, + one: 3.1, + zero: 3, + }); + return { withoutControls, controlsBoth, controlsRight }; + }, + template: ` +
+
+ + + + + +
+
+ + + + + +
+
+ + + + + +
+
+ `, + }), +} satisfies Story; + +export const MinMax = { + name: 'Min/Max', + render: () => ({ + components: { InputNumber, N8nInputLabel }, + setup() { + const minValue = ref(5); + const maxValue = ref(50); + return { minValue, maxValue }; + }, + template: ` +
+ + + + + + +
+ `, + }), +} satisfies Story; + +export const Disabled = { + render: () => ({ + components: { InputNumber, N8nInputLabel }, + setup() { + const withoutControls = ref(42); + const controlsBoth = ref(42); + const controlsRight = ref(42); + return { withoutControls, controlsBoth, controlsRight }; + }, + template: ` +
+ + + + + + + + + +
+ `, + }), +} satisfies Story; + +export const CustomButtons = { + render: (args) => ({ + components: { InputNumber, N8nIcon }, + setup() { + const value = ref(args.modelValue); + return { args, value }; + }, + template: ` +
+ + + + +
+ `, + }), + args: { + modelValue: 5, + min: 1, + max: 99, + controls: true, + controlsPosition: 'both', + }, +} satisfies Story; diff --git a/packages/frontend/@n8n/design-system/src/v2/components/InputNumber/InputNumber.test.ts b/packages/frontend/@n8n/design-system/src/components/N8nInputNumber/InputNumber.test.ts similarity index 71% rename from packages/frontend/@n8n/design-system/src/v2/components/InputNumber/InputNumber.test.ts rename to packages/frontend/@n8n/design-system/src/components/N8nInputNumber/InputNumber.test.ts index 62abca963f9..c36396f7a37 100644 --- a/packages/frontend/@n8n/design-system/src/v2/components/InputNumber/InputNumber.test.ts +++ b/packages/frontend/@n8n/design-system/src/components/N8nInputNumber/InputNumber.test.ts @@ -1,9 +1,11 @@ import userEvent from '@testing-library/user-event'; import { render, waitFor } from '@testing-library/vue'; +import { mount } from '@vue/test-utils'; +import type { InputNumberExposed } from './InputNumber.types'; import InputNumber from './InputNumber.vue'; -describe('v2/components/InputNumber', () => { +describe('components/N8nInputNumber', () => { describe('rendering', () => { it('should render with placeholder text', () => { const wrapper = render(InputNumber, { @@ -488,6 +490,21 @@ describe('v2/components/InputNumber', () => { }); describe('typed min/max', () => { + async function pasteText(text: string) { + const clipboardData = new DataTransfer(); + clipboardData.setData('text', text); + await userEvent.paste(clipboardData); + } + + function dispatchPaste(input: HTMLInputElement, text: string) { + const clipboardData = new DataTransfer(); + clipboardData.setData('text', text); + const event = new Event('paste', { bubbles: true, cancelable: true }); + Object.defineProperty(event, 'clipboardData', { value: clipboardData }); + input.dispatchEvent(event); + return event; + } + it('should clamp value below min on blur', async () => { const wrapper = render(InputNumber, { props: { @@ -501,7 +518,7 @@ describe('v2/components/InputNumber', () => { throw new Error('Expected input element'); } - // Min blocks out-of-range keystrokes; set the DOM value then blur to exercise clamping. + // Typing below min is allowed (1 can become 15); set the DOM value then blur to clamp. await userEvent.click(input); input.value = '5'; await userEvent.tab(); @@ -532,6 +549,156 @@ describe('v2/components/InputNumber', () => { expect(input).toHaveValue('10'); }); }); + + it('should ignore keystrokes that would exceed max', async () => { + const wrapper = render(InputNumber, { + props: { + defaultValue: 5, + max: 10, + controls: false, + }, + }); + const input = wrapper.container.querySelector('input'); + expect(input).toBeTruthy(); + if (!(input instanceof HTMLInputElement)) { + throw new Error('Expected input element'); + } + + await userEvent.click(input); + await userEvent.type(input, '50'); + + expect(input).toHaveValue('5'); + }); + + it('should allow typing a value equal to max', async () => { + const wrapper = render(InputNumber, { + props: { + defaultValue: 1, + max: 10, + controls: false, + }, + }); + const input = wrapper.container.querySelector('input'); + expect(input).toBeTruthy(); + if (!(input instanceof HTMLInputElement)) { + throw new Error('Expected input element'); + } + + await userEvent.click(input); + await userEvent.type(input, '10'); + + expect(input).toHaveValue('10'); + }); + + it('should ignore pasted values that would exceed max', async () => { + const wrapper = render(InputNumber, { + props: { + defaultValue: 5, + max: 10, + controls: false, + }, + }); + const input = wrapper.container.querySelector('input'); + expect(input).toBeTruthy(); + if (!(input instanceof HTMLInputElement)) { + throw new Error('Expected input element'); + } + + await userEvent.click(input); + await pasteText('50'); + + expect(input).toHaveValue('5'); + }); + + it('should reject over-max paste from clipboardData when beforeinput data is empty', async () => { + const wrapper = render(InputNumber, { + props: { + defaultValue: 5, + max: 10, + controls: false, + }, + }); + const input = wrapper.container.querySelector('input'); + expect(input).toBeTruthy(); + if (!(input instanceof HTMLInputElement)) { + throw new Error('Expected input element'); + } + + await userEvent.click(input); + const event = dispatchPaste(input, '50'); + + expect(event.defaultPrevented).toBe(true); + }); + + it('should allow pasting a value equal to max', async () => { + const wrapper = render(InputNumber, { + props: { + defaultValue: 5, + max: 10, + controls: false, + }, + }); + const input = wrapper.container.querySelector('input'); + expect(input).toBeTruthy(); + if (!(input instanceof HTMLInputElement)) { + throw new Error('Expected input element'); + } + + await userEvent.click(input); + await pasteText('10'); + + expect(input).toHaveValue('10'); + }); + + it('should still bubble paste so consumers can handle expressions', async () => { + const wrapper = render(InputNumber, { + props: { + defaultValue: 5, + max: 10, + controls: false, + }, + }); + const root = wrapper.getByTestId('input-number'); + const bubbled: Event[] = []; + root.addEventListener('paste', (event) => bubbled.push(event)); + + const input = wrapper.container.querySelector('input'); + expect(input).toBeTruthy(); + if (!(input instanceof HTMLInputElement)) { + throw new Error('Expected input element'); + } + + await userEvent.click(input); + await pasteText('{{ $json.foo }}'); + + expect(bubbled).toHaveLength(1); + expect(bubbled[0].defaultPrevented).toBe(false); + }); + + it('should bubble over-max paste after rejecting it', async () => { + const wrapper = render(InputNumber, { + props: { + defaultValue: 5, + max: 10, + controls: false, + }, + }); + const root = wrapper.getByTestId('input-number'); + const bubbled: Event[] = []; + root.addEventListener('paste', (event) => bubbled.push(event)); + + const input = wrapper.container.querySelector('input'); + expect(input).toBeTruthy(); + if (!(input instanceof HTMLInputElement)) { + throw new Error('Expected input element'); + } + + await userEvent.click(input); + const event = dispatchPaste(input, '50'); + + expect(event.defaultPrevented).toBe(true); + expect(bubbled).toHaveLength(1); + }); }); describe('external v-model updates', () => { @@ -551,4 +718,44 @@ describe('v2/components/InputNumber', () => { }); }); }); + + describe('exposed methods', () => { + const mounted: Array<{ unmount: () => void }> = []; + + afterEach(() => { + while (mounted.length) mounted.pop()?.unmount(); + }); + + it('should focus, blur, and select the nested input via the template ref', async () => { + const wrapper = mount(InputNumber, { + attachTo: document.body, + props: { + modelValue: 42, + controls: false, + }, + }); + mounted.push(wrapper); + + const input = wrapper.find('input').element; + if (!(input instanceof HTMLInputElement)) { + throw new Error('Expected input element'); + } + + const exposed = wrapper.vm as unknown as InputNumberExposed; + + expect(typeof exposed.focus).toBe('function'); + expect(typeof exposed.blur).toBe('function'); + expect(typeof exposed.select).toBe('function'); + + exposed.focus(); + expect(document.activeElement).toBe(input); + + exposed.select(); + expect(input.selectionStart).toBe(0); + expect(input.selectionEnd).toBe(input.value.length); + + exposed.blur(); + expect(document.activeElement).not.toBe(input); + }); + }); }); diff --git a/packages/frontend/@n8n/design-system/src/v2/components/InputNumber/InputNumber.types.ts b/packages/frontend/@n8n/design-system/src/components/N8nInputNumber/InputNumber.types.ts similarity index 90% rename from packages/frontend/@n8n/design-system/src/v2/components/InputNumber/InputNumber.types.ts rename to packages/frontend/@n8n/design-system/src/components/N8nInputNumber/InputNumber.types.ts index 8aa4b1459a5..04e8850543d 100644 --- a/packages/frontend/@n8n/design-system/src/v2/components/InputNumber/InputNumber.types.ts +++ b/packages/frontend/@n8n/design-system/src/components/N8nInputNumber/InputNumber.types.ts @@ -17,6 +17,12 @@ export type InputNumberEmits = NumberFieldRootEmits & { blur: [event: FocusEvent]; }; +export interface InputNumberExposed { + focus: () => void; + blur: () => void; + select: () => void; +} + export type InputNumberControlSlotProps = { ui: { class: string }; }; diff --git a/packages/frontend/@n8n/design-system/src/components/N8nInputNumber/InputNumber.vue b/packages/frontend/@n8n/design-system/src/components/N8nInputNumber/InputNumber.vue index 8777bcaa900..de69f957bd1 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nInputNumber/InputNumber.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nInputNumber/InputNumber.vue @@ -1,50 +1,393 @@ diff --git a/packages/frontend/@n8n/design-system/src/v2/components/InputNumber/component-input-number.md b/packages/frontend/@n8n/design-system/src/components/N8nInputNumber/component-input-number.md similarity index 78% rename from packages/frontend/@n8n/design-system/src/v2/components/InputNumber/component-input-number.md rename to packages/frontend/@n8n/design-system/src/components/N8nInputNumber/component-input-number.md index c411339661c..4cc50a27f32 100644 --- a/packages/frontend/@n8n/design-system/src/v2/components/InputNumber/component-input-number.md +++ b/packages/frontend/@n8n/design-system/src/components/N8nInputNumber/component-input-number.md @@ -4,8 +4,7 @@ A number input component allowing users to enter and adjust numeric values. Supp Visual affordances (sizes, inset border, hover/focus, disabled) match `N8nInput` via shared `input` / `focus` mixins. Kept as a separate component rather than merged into `N8nInput` — number semantics (`min`/`max`/`step`/`precision`/controls) would broaden Input’s API and risk regressions. -- **Component Name:** N8nInputNumber2 (experimental; legacy Element Plus wrapper remains `N8nInputNumber`) -- **Element+ Component:** [ElInputNumber](https://element-plus.org/en-US/component/input-number.html) +- **Component Name:** N8nInputNumber - **Reka UI Component:** [NumberField](https://reka-ui.com/docs/components/number-field) - **Nuxt UI Component:** [InputNumber](https://ui.nuxt.com/docs/components/input-number) @@ -18,8 +17,8 @@ Extends Reka UI `NumberFieldRootProps` (except `formatOptions`, derived from `pr - `modelValue?: number | null` - Controlled value. Use with `v-model`. - `defaultValue?: number` - Uncontrolled initial value when `modelValue` is omitted. - `size?: 'mini' | 'small' | 'medium' | 'large' | 'xlarge'` - Size variant. Default: `'medium'` -- `min?: number` - Minimum allowed value. -- `max?: number` - Maximum allowed value. +- `min?: number` - Minimum allowed value. Values below min clamp on blur. +- `max?: number` - Maximum allowed value. Keystrokes and pastes that would exceed max are ignored; the value also clamps on blur. - `step?: number` - Increment/decrement step amount. Default: `1` - `stepSnapping?: boolean` - When `true`, typed values snap to `step` on blur. Default: `false` (Reka defaults to `true`; we override so decimals are preserved unless opted in). - `precision?: number` - Decimal places. Maps to Reka `formatOptions` fraction digits. @@ -37,6 +36,14 @@ Extends Reka UI `NumberFieldRootProps` (except `formatOptions`, derived from `pr Clicking the input selects the full value. Increment/decrement controls do not. +**Exposed methods** (template ref) + +Same contract as `N8nInput`, so `ParameterInput` can programmatically focus, blur, and select number fields. + +- `focus()` - Focus the nested input. +- `blur()` - Blur the nested input. +- `select()` - Select the input value. + **Slots** - `increment` - Fully custom increment control. Scope: `{ ui: { class } }`. Default: button with plus/chevron icon. @@ -49,20 +56,20 @@ Clicking the input selects the full value. Increment/decrement controls do not. ```vue ``` **Uncontrolled:** ```vue - + ``` **With step and precision:** @@ -70,13 +77,13 @@ const value = ref(0) ```vue