feat(editor): Complete N8nInputNumber2 adoption and remove the legacy InputNumber (#36598)

This commit is contained in:
Sebastien Powell 2026-09-01 08:17:31 +00:00 committed by GitHub
parent 5c7e0062cb
commit 73ac8e43c8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 1064 additions and 1126 deletions

View File

@ -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<typeof InputNumber>;
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<typeof meta>;
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: `
<div style="${storyPadding} max-width: 320px;">
<InputNumber v-bind="args" v-model="value" />
</div>
`,
}),
} satisfies Story;
const InputNumberControlledUncontrolledDemo = defineComponent({
name: 'InputNumberControlledUncontrolledDemo',
components: { InputNumber, N8nInputLabel, N8nText },
setup() {
const value = ref(5);
return { value, onUpdate: action('update:modelValue') };
},
template:
'<n8n-input-number v-bind="args" v-model="val" @update:modelValue="onUpdateModelValue" />',
data() {
return {
val: null,
};
},
methods,
template: `
<div style="padding: var(--spacing--xl); display: flex; flex-direction: column; gap: var(--spacing--xl); max-width: 360px;">
<section style="display: flex; flex-direction: column; gap: var(--spacing--xs);">
<N8nInputLabel label="Controlled" />
<N8nText size="small" color="text-light">
Parent-controlled value via <code>v-model</code>. Use the buttons below to set the value externally.
</N8nText>
<InputNumber
key="controlled"
v-model="value"
:min="0"
:max="10"
:controls="true"
controls-position="both"
@update:model-value="onUpdate"
/>
<div style="display: flex; gap: var(--spacing--2xs); flex-wrap: wrap;">
<button type="button" style="padding: 4px 12px; font-size: 13px; cursor: pointer;" @click="value = 0">
Set to 0
</button>
<button type="button" style="padding: 4px 12px; font-size: 13px; cursor: pointer;" @click="value = 5">
Set to 5
</button>
<button type="button" style="padding: 4px 12px; font-size: 13px; cursor: pointer;" @click="value = 10">
Set to 10
</button>
</div>
<N8nText size="small">Selected: <strong>{{ value }}</strong></N8nText>
</section>
<section style="display: flex; flex-direction: column; gap: var(--spacing--xs);">
<N8nInputLabel label="Uncontrolled" />
<N8nText size="small" color="text-light">
Initial value set with <code>defaultValue="3"</code>. The parent does not track changes.
</N8nText>
<InputNumber
key="uncontrolled"
:default-value="3"
:min="0"
:max="10"
:controls="true"
controls-position="both"
/>
</section>
</div>
`,
});
export const Input = Template.bind({});
Input.args = {
placeholder: 'placeholder...',
controls: false,
export const ControlledUncontrolled: Story = {
name: 'Controlled/Uncontrolled',
render: () => ({
components: { InputNumberControlledUncontrolledDemo },
template: '<InputNumberControlledUncontrolledDemo />',
}),
};
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: `
<div style="${storyPadding} max-width: 320px;">
<InputNumber v-bind="args" v-model="value" :controls="true" controls-position="both" />
</div>
`,
}),
args: {
modelValue: 5,
min: 0,
max: 10,
controls: true,
controlsPosition: 'both',
},
template:
'<div> <n8n-input-number style="margin-bottom:10px" v-bind="args" v-model="val" @update:modelValue="onUpdateModelValue" /> <n8n-input-number style="margin-bottom:10px" v-bind="args" size="medium" v-model="val" @update:modelValue="onUpdateModelValue" /> <n8n-input-number style="margin-bottom:10px" v-bind="args" size="small" v-model="val" @update:modelValue="onUpdateModelValue" /> <n8n-input-number style="margin-bottom:10px" v-bind="args" v-model="val" size="mini" @update:modelValue="onUpdateModelValue" /> </div>',
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: `
<div style="${storyPadding} max-width: 320px;">
<InputNumber v-bind="args" v-model="value" :controls="false" />
</div>
`,
}),
args: {
modelValue: 5,
min: 0,
max: 10,
controls: false,
},
template:
'<div> <n8n-input-number style="margin-bottom:10px" v-bind="args" v-model="val" @update:modelValue="onUpdateModelValue" /> <n8n-input-number style="margin-bottom:10px" v-bind="args" size="medium" v-model="val" @update:modelValue="onUpdateModelValue" /> <n8n-input-number style="margin-bottom:10px" v-bind="args" size="small" v-model="val" @update:modelValue="onUpdateModelValue" /> <n8n-input-number style="margin-bottom:10px" v-bind="args" v-model="val" size="mini" @update:modelValue="onUpdateModelValue" /> <n8n-input-number controls-position="right" style="margin-bottom:10px" v-bind="args" v-model="val" @update:modelValue="onUpdateModelValue" /> <n8n-input-number controls-position="right" style="margin-bottom:10px" v-bind="args" size="medium" v-model="val" @update:modelValue="onUpdateModelValue" /> <n8n-input-number controls-position="right" style="margin-bottom:10px" v-bind="args" size="small" v-model="val" @update:modelValue="onUpdateModelValue" /> <n8n-input-number controls-position="right" style="margin-bottom:10px" v-bind="args" v-model="val" size="mini" @update:modelValue="onUpdateModelValue" /> </div>',
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: `
<div style="${storyPadding} display: flex; gap: var(--spacing--xl); flex-wrap: wrap;">
<div style="width: 200px; display: flex; flex-direction: column; gap: var(--spacing--xs);">
<N8nInputLabel label="Without controls" />
<InputNumber v-model="withoutControls.mini" size="mini" placeholder="mini" :controls="false" />
<InputNumber v-model="withoutControls.small" size="small" placeholder="small" :controls="false" />
<InputNumber v-model="withoutControls.medium" size="medium" placeholder="medium" :controls="false" />
<InputNumber v-model="withoutControls.large" size="large" placeholder="large" :controls="false" />
<InputNumber v-model="withoutControls.xlarge" size="xlarge" placeholder="xlarge" :controls="false" />
</div>
<div style="width: 200px; display: flex; flex-direction: column; gap: var(--spacing--xs);">
<N8nInputLabel label="With controls (both)" />
<InputNumber v-model="controlsBoth.mini" size="mini" :controls="true" controls-position="both" />
<InputNumber v-model="controlsBoth.small" size="small" :controls="true" controls-position="both" />
<InputNumber v-model="controlsBoth.medium" size="medium" :controls="true" controls-position="both" />
<InputNumber v-model="controlsBoth.large" size="large" :controls="true" controls-position="both" />
<InputNumber v-model="controlsBoth.xlarge" size="xlarge" :controls="true" controls-position="both" />
</div>
<div style="width: 200px; display: flex; flex-direction: column; gap: var(--spacing--xs);">
<N8nInputLabel label="With controls (right)" />
<InputNumber v-model="controlsRight.mini" size="mini" :controls="true" controls-position="right" />
<InputNumber v-model="controlsRight.small" size="small" :controls="true" controls-position="right" />
<InputNumber v-model="controlsRight.medium" size="medium" :controls="true" controls-position="right" />
<InputNumber v-model="controlsRight.large" size="large" :controls="true" controls-position="right" />
<InputNumber v-model="controlsRight.xlarge" size="xlarge" :controls="true" controls-position="right" />
</div>
</div>
`,
}),
} 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: `
<div style="${storyPadding} display: flex; gap: var(--spacing--xl); flex-wrap: wrap;">
<div style="width: 200px; display: flex; flex-direction: column; gap: var(--spacing--xs);">
<N8nInputLabel label="Without controls" />
<InputNumber v-model="withoutControls.none" placeholder="No precision" :controls="false" />
<InputNumber v-model="withoutControls.two" :precision="2" placeholder="Precision: 2" :controls="false" />
<InputNumber v-model="withoutControls.one" :precision="1" placeholder="Precision: 1" :controls="false" />
<InputNumber v-model="withoutControls.zero" :precision="0" placeholder="Precision: 0" :controls="false" />
</div>
<div style="width: 200px; display: flex; flex-direction: column; gap: var(--spacing--xs);">
<N8nInputLabel label="With controls (both)" />
<InputNumber
v-model="controlsBoth.none"
:controls="true"
controls-position="both"
/>
<InputNumber
v-model="controlsBoth.two"
:precision="2"
:step="0.01"
:controls="true"
controls-position="both"
/>
<InputNumber
v-model="controlsBoth.one"
:precision="1"
:step="0.1"
:controls="true"
controls-position="both"
/>
<InputNumber
v-model="controlsBoth.zero"
:precision="0"
:controls="true"
controls-position="both"
/>
</div>
<div style="width: 200px; display: flex; flex-direction: column; gap: var(--spacing--xs);">
<N8nInputLabel label="With controls (right)" />
<InputNumber
v-model="controlsRight.none"
:controls="true"
controls-position="right"
/>
<InputNumber
v-model="controlsRight.two"
:precision="2"
:step="0.01"
:controls="true"
controls-position="right"
/>
<InputNumber
v-model="controlsRight.one"
:precision="1"
:step="0.1"
:controls="true"
controls-position="right"
/>
<InputNumber
v-model="controlsRight.zero"
:precision="0"
:controls="true"
controls-position="right"
/>
</div>
</div>
`,
}),
} 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: `
<div style="${storyStack}">
<N8nInputLabel label="Min = 0">
<InputNumber v-model="minValue" :min="0" :controls="true" controls-position="both" />
</N8nInputLabel>
<N8nInputLabel label="Max = 100">
<InputNumber v-model="maxValue" :max="100" :controls="true" controls-position="both" />
</N8nInputLabel>
</div>
`,
}),
} 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: `
<div style="${storyStack}">
<N8nInputLabel label="Without controls">
<InputNumber v-model="withoutControls" placeholder="Disabled" :disabled="true" :controls="false" />
</N8nInputLabel>
<N8nInputLabel label="With controls (both)">
<InputNumber
v-model="controlsBoth"
:controls="true"
controls-position="both"
:disabled="true"
/>
</N8nInputLabel>
<N8nInputLabel label="With controls (right)">
<InputNumber
v-model="controlsRight"
:controls="true"
controls-position="right"
:disabled="true"
/>
</N8nInputLabel>
</div>
`,
}),
} satisfies Story;
export const CustomButtons = {
render: (args) => ({
components: { InputNumber, N8nIcon },
setup() {
const value = ref(args.modelValue);
return { args, value };
},
template: `
<div style="${storyPadding} max-width: 320px;">
<InputNumber v-bind="args" v-model="value" :controls="true" controls-position="both">
<template #decrement="{ ui }">
<button type="button" :class="ui.class" aria-label="Decrease">
<N8nIcon icon="chevron-left" size="small" />
</button>
</template>
<template #increment="{ ui }">
<button type="button" :class="ui.class" aria-label="Increase">
<N8nIcon icon="chevron-right" size="small" />
</button>
</template>
</InputNumber>
</div>
`,
}),
args: {
modelValue: 5,
min: 1,
max: 99,
controls: true,
controlsPosition: 'both',
},
} satisfies Story;

View File

@ -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);
});
});
});

View File

@ -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 };
};

View File

@ -1,50 +1,393 @@
<script setup lang="ts">
import { ElInputNumber } from 'element-plus';
import { computed } from 'vue';
import { reactiveOmit, reactivePick, unrefElement } from '@vueuse/core';
import {
NumberFieldRoot,
NumberFieldInput,
NumberFieldIncrement,
NumberFieldDecrement,
useForwardPropsEmits,
} from 'reka-ui';
import { computed, useAttrs, useCssModule, useTemplateRef } from 'vue';
import type { ElementPlusSizePropType, InputSize } from '../../types';
import Icon from '@n8n/design-system/components/N8nIcon/Icon.vue';
import { useI18n } from '@n8n/design-system/composables/useI18n';
type InputNumberProps = {
size?: InputSize;
min?: number;
max?: number;
step?: number;
precision?: number;
};
import type {
InputNumberProps,
InputNumberEmits,
InputNumberSlots,
InputNumberExposed,
} from './InputNumber.types';
defineOptions({ name: 'N8nInputNumber', inheritAttrs: false });
const attrs = useAttrs();
const rootClass = computed(() => attrs.class);
const rootAttrs = computed(() => reactiveOmit(attrs, ['class']));
const $style = useCssModule();
const { t } = useI18n();
const props = withDefaults(defineProps<InputNumberProps>(), {
size: undefined,
size: 'medium',
controls: true,
controlsPosition: 'right',
step: 1,
precision: undefined,
min: -Infinity,
max: Infinity,
stepSnapping: false,
});
const sizeMap: Record<InputSize, ElementPlusSizePropType> = {
mini: 'small',
small: 'small',
medium: 'default',
large: 'large',
xlarge: 'large',
const isControlsRight = computed(() => props.controls && props.controlsPosition === 'right');
const isControlsBoth = computed(() => props.controls && props.controlsPosition === 'both');
const emit = defineEmits<InputNumberEmits>();
defineSlots<InputNumberSlots>();
const inputRef = useTemplateRef('inputRef');
function getInput(): HTMLInputElement | null {
const el = unrefElement(inputRef);
return el instanceof HTMLInputElement ? el : null;
}
const focus = () => getInput()?.focus();
const blur = () => getInput()?.blur();
const select = () => getInput()?.select();
defineExpose<InputNumberExposed>({ focus, blur, select });
// Map precision to formatOptions - uses Intl.NumberFormatOptions
// When no precision is set, use maximumFractionDigits: 20 (the max allowed by Intl.NumberFormat)
// to preserve full decimal precision and avoid default rounding behavior
const formatOptions = computed<Intl.NumberFormatOptions>(() =>
props.precision !== undefined
? { maximumFractionDigits: props.precision, minimumFractionDigits: props.precision }
: { maximumFractionDigits: 20 },
);
const rootProps = useForwardPropsEmits(
reactivePick(
props,
'modelValue',
'defaultValue',
'min',
'max',
'step',
'stepSnapping',
'disabled',
'readonly',
'disableWheelChange',
'invertWheelChange',
'id',
'name',
'required',
'locale',
),
emit,
);
function onFocus(event: FocusEvent) {
emit('focus', event);
}
/** Select all only on direct input click — not when controls focus the field. */
function onInputClick(event: MouseEvent) {
const target = event.target;
if (target instanceof HTMLInputElement) {
target.select();
}
}
function nextInputValue(target: HTMLInputElement, inserted: string) {
return (
target.value.slice(0, target.selectionStart ?? 0) +
inserted +
target.value.slice(target.selectionEnd ?? 0)
);
}
function exceedsMax(value: string) {
if (props.max === undefined) return false;
const parsed = Number(value);
return !Number.isNaN(parsed) && parsed > props.max;
}
/**
* Reka's beforeinput validator only checks that characters form a number, not min/max.
* Reject complete values above max while typing; below-min values still clamp on blur
* so the user can type a larger number (e.g. 1 → 15 when min is 10).
*
* Paste is handled separately: `insertFromPaste` often has `event.data === null`.
*/
function onBeforeInput(event: InputEvent) {
if (event.defaultPrevented || props.max === undefined) return;
if (event.inputType.startsWith('delete') || event.inputType.startsWith('history')) return;
const target = event.target;
if (!(target instanceof HTMLInputElement)) return;
if (exceedsMax(nextInputValue(target, event.data ?? ''))) {
event.preventDefault();
}
}
/**
* Paste `beforeinput` may not include the clipboard text (`event.data` is null).
* Read `clipboardData` here and reject over-max values before they land in the field.
* Do not stop the event — consumers (e.g. expression paste) still need the bubble.
*/
function onPaste(event: ClipboardEvent) {
if (event.defaultPrevented || props.max === undefined) return;
const target = event.target;
if (!(target instanceof HTMLInputElement)) return;
const inserted = event.clipboardData?.getData('text') ?? '';
if (exceedsMax(nextInputValue(target, inserted))) {
event.preventDefault();
}
}
const sizes: Record<NonNullable<InputNumberProps['size']>, string> = {
mini: $style.mini,
small: $style.small,
medium: $style.medium,
large: $style.large,
xlarge: $style.xlarge,
};
const resolvedSize = computed(() => (props.size ? sizeMap[props.size] : undefined));
const sizeClass = computed(() => sizes[props.size ?? 'medium']);
</script>
<template>
<ElInputNumber
:size="resolvedSize"
:min="min"
:max="max"
:step="step"
:precision="precision"
:class="$style.inputNumber"
v-bind="$attrs"
/>
<NumberFieldRoot
data-test-id="input-number"
v-bind="{ ...rootProps, ...rootAttrs, formatOptions }"
:min="props.min"
:max="props.max"
:class="[
$style.inputNumber,
sizeClass,
rootClass,
{
[$style.isDisabled]: props.disabled,
[$style.isControlsBoth]: isControlsBoth,
},
]"
>
<NumberFieldDecrement v-if="isControlsBoth" as-child>
<slot name="decrement" :ui="{ class: [$style.button, $style.buttonDecrement].join(' ') }">
<button
type="button"
:class="[$style.button, $style.buttonDecrement]"
:aria-label="t('nds.inputNumber.decrease')"
>
<Icon icon="minus" size="small" />
</button>
</slot>
</NumberFieldDecrement>
<NumberFieldInput
ref="inputRef"
:class="$style.input"
:placeholder="placeholder"
@focus="onFocus"
@click="onInputClick"
@beforeinput="onBeforeInput"
@paste="onPaste"
@blur="emit('blur', $event)"
/>
<NumberFieldIncrement v-if="isControlsBoth" as-child>
<slot name="increment" :ui="{ class: [$style.button, $style.buttonIncrement].join(' ') }">
<button
type="button"
:class="[$style.button, $style.buttonIncrement]"
:aria-label="t('nds.inputNumber.increase')"
>
<Icon icon="plus" size="small" />
</button>
</slot>
</NumberFieldIncrement>
<div v-if="isControlsRight" :class="$style.controlsWrapper">
<NumberFieldIncrement as-child>
<slot name="increment" :ui="{ class: [$style.button, $style.buttonUp].join(' ') }">
<button
type="button"
:class="[$style.button, $style.buttonUp]"
:aria-label="t('nds.inputNumber.increase')"
>
<Icon icon="chevron-up" size="xsmall" />
</button>
</slot>
</NumberFieldIncrement>
<NumberFieldDecrement as-child>
<slot name="decrement" :ui="{ class: [$style.button, $style.buttonDown].join(' ') }">
<button
type="button"
:class="[$style.button, $style.buttonDown]"
:aria-label="t('nds.inputNumber.decrease')"
>
<Icon icon="chevron-down" size="xsmall" />
</button>
</slot>
</NumberFieldDecrement>
</div>
</NumberFieldRoot>
</template>
<style lang="scss" module>
@use '../../css/mixins/focus';
@use '../../css/mixins/input' as input-mixin;
.inputNumber {
--input--color--background: light-dark(var(--color--neutral-white), var(--color--neutral-950));
@include input-mixin.size-variables('medium');
@include input-mixin.theme-variables(var(--border-color));
display: inline-flex;
width: 100%;
min-height: var(--input--height);
border-radius: var(--input--radius);
background-color: var(--input--color--background);
box-shadow:
var(--input--shadow),
inset var(--input--border--shadow);
font-size: var(--input--font-size);
color: var(--input--color--text);
@include focus.focus-within-ring;
&:hover:not(.isDisabled):not(:focus-within) {
box-shadow:
var(--input--shadow--hover),
inset var(--input--border--shadow--hover);
}
&:focus-within {
box-shadow:
var(--input--shadow--focus),
inset var(--input--border--shadow--focus);
}
}
.isDisabled {
cursor: not-allowed;
opacity: 0.6;
}
.input {
flex: 1;
min-width: 0;
min-height: var(--input--height);
border: none;
background: transparent;
outline: none;
font-size: var(--input--font-size);
color: var(--input--color--text);
padding: 0 var(--input--padding);
&::placeholder {
color: var(--input--placeholder--color);
}
&:disabled {
cursor: not-allowed;
color: var(--input--color--disabled);
}
}
.isControlsBoth .input {
text-align: center;
padding-inline: var(--spacing--3xs);
}
.button {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border: none;
background: transparent;
cursor: pointer;
color: var(--icon-color);
&:hover:not(:disabled) {
color: var(--icon-color--strong);
background-color: var(--background--hover);
}
&:disabled,
&[data-disabled] {
cursor: not-allowed;
color: var(--icon-color--subtle);
}
}
.buttonDecrement {
width: var(--input--height);
border-right: var(--border-width, 1px) solid var(--input--border-color);
border-radius: var(--input--radius) 0 0 var(--input--radius);
&:hover:not(:disabled):not([data-disabled]) {
border-right-color: var(--input--border-color--hover);
}
}
.buttonIncrement {
width: var(--input--height);
border-left: var(--border-width, 1px) solid var(--input--border-color);
border-radius: 0 var(--input--radius) var(--input--radius) 0;
&:hover:not(:disabled):not([data-disabled]) {
border-left-color: var(--input--border-color--hover);
}
}
.controlsWrapper {
display: flex;
flex-direction: column;
border-left: var(--border-width, 1px) solid var(--input--border-color);
&:hover:has(button:not(:disabled):not([data-disabled])) {
border-left-color: var(--input--border-color--hover);
.buttonUp {
border-bottom-color: var(--input--border-color--hover);
}
}
}
.buttonUp,
.buttonDown {
flex: 1;
}
.buttonUp {
border-bottom: var(--border-width, 1px) solid var(--input--border-color);
border-radius: 0 var(--input--radius) 0 0;
}
.buttonDown {
border-radius: 0 0 var(--input--radius) 0;
}
.mini {
@include input-mixin.size-variables('mini');
}
.small {
@include input-mixin.size-variables('small');
}
.medium {
@include input-mixin.size-variables('medium');
}
.large {
@include input-mixin.size-variables('large');
}
.xlarge {
@include input-mixin.size-variables('xlarge');
}
</style>

View File

@ -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
<script setup lang="ts">
import { ref } from 'vue'
import { N8nInputNumber2 } from '@n8n/design-system'
import { N8nInputNumber } from '@n8n/design-system'
const value = ref(0)
</script>
<template>
<N8nInputNumber2 v-model="value" :min="0" :max="100" />
<N8nInputNumber v-model="value" :min="0" :max="100" />
</template>
```
**Uncontrolled:**
```vue
<N8nInputNumber2 :default-value="3" :min="0" :max="10" />
<N8nInputNumber :default-value="3" :min="0" :max="10" />
```
**With step and precision:**
@ -70,13 +77,13 @@ const value = ref(0)
```vue
<script setup lang="ts">
import { ref } from 'vue'
import { N8nInputNumber2 } from '@n8n/design-system'
import { N8nInputNumber } from '@n8n/design-system'
const price = ref(0)
</script>
<template>
<N8nInputNumber2
<N8nInputNumber
v-model="price"
:step="0.01"
:precision="2"
@ -89,13 +96,13 @@ const price = ref(0)
**Without controls:**
```vue
<N8nInputNumber2 v-model="value" :controls="false" />
<N8nInputNumber v-model="value" :controls="false" />
```
**With custom control buttons:**
```vue
<N8nInputNumber2 v-model="quantity" :min="1" :max="99" controls-position="both">
<N8nInputNumber v-model="quantity" :min="1" :max="99" controls-position="both">
<template #decrement="{ ui }">
<button type="button" :class="ui.class" aria-label="Decrease">
<N8nIcon icon="minus" size="small" />
@ -106,5 +113,5 @@ const price = ref(0)
<N8nIcon icon="plus" size="small" />
</button>
</template>
</N8nInputNumber2>
</N8nInputNumber>
```

View File

@ -1,3 +1,5 @@
import N8nInputNumber from './InputNumber.vue';
export default N8nInputNumber;
export { N8nInputNumber };
export type * from './InputNumber.types';

View File

@ -68,6 +68,7 @@ export { default as N8nInfoTip } from './N8nInfoTip';
export { default as N8nInput } from './N8nInput';
export { default as N8nInputLabel } from './N8nInputLabel';
export { default as N8nInputNumber } from './N8nInputNumber';
export type * from './N8nInputNumber/InputNumber.types';
export { default as N8nLink } from './N8nLink';
export { default as N8nLogo } from './N8nLogo';
export { default as N8nLoading } from './N8nLoading';

View File

@ -457,7 +457,6 @@ $input-small-height: 30px !default;
$input-mini-font-size: 12px;
/// height||Other|4
$input-mini-height: 26px !default;
$input-number-control-border-radius: 3px;
/* Cascader
-------------------------- */

View File

@ -8,7 +8,6 @@
@use './dropdown.scss';
@use './menu.scss';
@use './input.scss';
@use './input-number.scss';
@use './radio.scss';
@use './checkbox.scss';
@use './switch.scss';

View File

@ -1,191 +0,0 @@
@use 'mixins/mixins';
@use './common/var';
@use 'input';
@include mixins.b(input-number) {
position: relative;
display: inline-block;
line-height: #{var.$input-height - 2};
width: 100%;
/* Chrome, Safari, Edge, Opera */
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Firefox */
input[type='number'] {
-moz-appearance: textfield !important;
}
.el-input {
display: block;
&__inner {
-webkit-appearance: none;
padding-left: #{var.$input-height + 10};
padding-right: #{var.$input-height + 10};
text-align: center;
}
}
@include mixins.e((increase, decrease)) {
--input--color--disabled: var(--color--text--tint-1);
position: absolute;
z-index: 1;
top: 1px;
bottom: 1px;
display: flex;
align-items: center;
justify-content: center;
width: var.$input-height;
height: auto;
background: var.$background-color-base;
color: var(--color--text--shade-1);
cursor: pointer;
font-size: 13px;
&:hover {
color: var(--color--primary);
&:not(.is-disabled) ~ .el-input .el-input__inner:not(.is-disabled) {
border-color: var.$input-focus-border;
}
}
&.is-disabled {
color: var.$disabled-color-base;
cursor: not-allowed;
}
}
@include mixins.e(increase) {
right: 1px;
border-radius: 0 var.$input-number-control-border-radius var.$input-number-control-border-radius
0;
border-left: var(--border);
}
@include mixins.e(decrease) {
left: 1px;
border-radius: var.$input-number-control-border-radius 0 0
var.$input-number-control-border-radius;
border-right: var(--border);
}
@include mixins.when(disabled) {
@include mixins.e((increase, decrease)) {
border-color: var.$disabled-border-base;
color: var.$disabled-border-base;
&:hover {
color: var.$disabled-border-base;
cursor: not-allowed;
}
}
}
@include mixins.m(medium) {
line-height: #{var.$input-medium-height - 4};
@include mixins.e((increase, decrease)) {
width: var.$input-medium-height;
font-size: var.$input-medium-font-size;
}
.el-input__inner {
padding-left: #{var.$input-medium-height + 7};
padding-right: #{var.$input-medium-height + 7};
}
}
@include mixins.m(small) {
line-height: #{var.$input-small-height - 4};
@include mixins.e((increase, decrease)) {
width: var.$input-small-height;
font-size: var.$input-small-font-size;
[class*='el-icon'] {
transform: scale(0.9);
}
}
.el-input__inner {
padding-left: #{var.$input-small-height + 7};
padding-right: #{var.$input-small-height + 7};
}
}
@include mixins.m(mini) {
line-height: #{var.$input-mini-height - 4};
@include mixins.e((increase, decrease)) {
width: var.$input-mini-height;
font-size: var.$input-mini-font-size;
[class*='el-icon'] {
transform: scale(0.8);
}
}
.el-input__inner {
padding-left: #{var.$input-mini-height + 7};
padding-right: #{var.$input-mini-height + 7};
}
}
@include mixins.when(without-controls) {
.el-input__inner {
text-align: left;
padding-left: var(--spacing--2xs);
padding-right: var(--spacing--2xs);
}
}
@include mixins.when(controls-right) {
.el-input__inner {
padding-left: var(--spacing--2xs);
padding-right: #{var.$input-height + 4};
}
&.el-input-number--medium .el-input__inner {
padding-right: #{var.$input-medium-height + 4};
}
&.el-input-number--small .el-input__inner {
padding-right: #{var.$input-small-height + 4};
}
&.el-input-number--mini .el-input__inner {
padding-left: var(--spacing--4xs);
padding-right: #{var.$input-mini-height + 4};
}
@include mixins.e((increase, decrease)) {
height: calc((100% - 1px) / 2);
bottom: auto;
[class*='el-icon'] {
transform: scale(0.8);
}
}
@include mixins.e(increase) {
border-radius: 0 var.$input-number-control-border-radius 0 0;
border-bottom: var(--border);
}
@include mixins.e(decrease) {
right: 1px;
bottom: 1px;
top: auto;
left: auto;
border-right: none;
border-left: var(--border);
border-radius: 0 0 var.$input-number-control-border-radius 0;
}
}
}

View File

@ -20,8 +20,6 @@ export type * from './v2/components/Checkbox/Checkbox.types';
export type * from './components/N8nPagination/Pagination.types';
export { default as N8nLoading2 } from './v2/components/Loading/Loading.vue';
export type * from './v2/components/Loading/Loading.types';
export { default as N8nInputNumber2 } from './v2/components/InputNumber/InputNumber.vue';
export type * from './v2/components/InputNumber/InputNumber.types';
export { default as N8nRadioGroupItem } from './components/N8nRadioGroup/RadioGroupItem.vue';
export { default as N8nRadioGroup } from './components/N8nRadioGroup/RadioGroup.vue';
export type * from './components/N8nRadioGroup/RadioGroupItem.types';

View File

@ -1,421 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite';
import { action } from 'storybook/actions';
import { defineComponent, reactive, ref } from 'vue';
import InputNumber from './InputNumber.vue';
import N8nIcon from '../../../components/N8nIcon/Icon.vue';
import N8nInputLabel from '../../../components/N8nInputLabel/InputLabel.vue';
import N8nText from '../../../components/N8nText/Text.vue';
const meta = {
title: 'Experimental/InputNumber',
component: InputNumber,
parameters: {
docs: {
description: {
component:
'Number input built on Reka UI NumberField. Visual affordances match `N8nInput` (shared input size/theme tokens, inset border, focus ring).',
},
source: { type: 'dynamic' },
},
},
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<typeof InputNumber>;
export default meta;
type Story = StoryObj<typeof meta>;
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: `
<div style="${storyPadding} max-width: 320px;">
<InputNumber v-bind="args" v-model="value" />
</div>
`,
}),
} satisfies Story;
const InputNumberControlledUncontrolledDemo = defineComponent({
name: 'InputNumberControlledUncontrolledDemo',
components: { InputNumber, N8nInputLabel, N8nText },
setup() {
const value = ref(5);
return { value, onUpdate: action('update:modelValue') };
},
template: `
<div style="padding: var(--spacing--xl); display: flex; flex-direction: column; gap: var(--spacing--xl); max-width: 360px;">
<section style="display: flex; flex-direction: column; gap: var(--spacing--xs);">
<N8nInputLabel label="Controlled" />
<N8nText size="small" color="text-light">
Parent-controlled value via <code>v-model</code>. Use the buttons below to set the value externally.
</N8nText>
<InputNumber
key="controlled"
v-model="value"
:min="0"
:max="10"
:controls="true"
controls-position="both"
@update:model-value="onUpdate"
/>
<div style="display: flex; gap: var(--spacing--2xs); flex-wrap: wrap;">
<button type="button" style="padding: 4px 12px; font-size: 13px; cursor: pointer;" @click="value = 0">
Set to 0
</button>
<button type="button" style="padding: 4px 12px; font-size: 13px; cursor: pointer;" @click="value = 5">
Set to 5
</button>
<button type="button" style="padding: 4px 12px; font-size: 13px; cursor: pointer;" @click="value = 10">
Set to 10
</button>
</div>
<N8nText size="small">Selected: <strong>{{ value }}</strong></N8nText>
</section>
<section style="display: flex; flex-direction: column; gap: var(--spacing--xs);">
<N8nInputLabel label="Uncontrolled" />
<N8nText size="small" color="text-light">
Initial value set with <code>defaultValue="3"</code>. The parent does not track changes.
</N8nText>
<InputNumber
key="uncontrolled"
:default-value="3"
:min="0"
:max="10"
:controls="true"
controls-position="both"
/>
</section>
</div>
`,
});
export const ControlledUncontrolled: Story = {
name: 'Controlled/Uncontrolled',
render: () => ({
components: { InputNumberControlledUncontrolledDemo },
template: '<InputNumberControlledUncontrolledDemo />',
}),
};
export const WithControlsBoth = {
render: (args) => ({
components: { InputNumber },
setup() {
const value = ref(args.modelValue);
return { args, value };
},
template: `
<div style="${storyPadding} max-width: 320px;">
<InputNumber v-bind="args" v-model="value" :controls="true" controls-position="both" />
</div>
`,
}),
args: {
modelValue: 5,
min: 0,
max: 10,
controls: true,
controlsPosition: 'both',
},
} satisfies Story;
export const NoControls = {
render: (args) => ({
components: { InputNumber },
setup() {
const value = ref(args.modelValue);
return { args, value };
},
template: `
<div style="${storyPadding} max-width: 320px;">
<InputNumber v-bind="args" v-model="value" :controls="false" />
</div>
`,
}),
args: {
modelValue: 5,
min: 0,
max: 10,
controls: false,
},
} satisfies Story;
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: `
<div style="${storyPadding} display: flex; gap: var(--spacing--xl); flex-wrap: wrap;">
<div style="width: 200px; display: flex; flex-direction: column; gap: var(--spacing--xs);">
<N8nInputLabel label="Without controls" />
<InputNumber v-model="withoutControls.mini" size="mini" placeholder="mini" :controls="false" />
<InputNumber v-model="withoutControls.small" size="small" placeholder="small" :controls="false" />
<InputNumber v-model="withoutControls.medium" size="medium" placeholder="medium" :controls="false" />
<InputNumber v-model="withoutControls.large" size="large" placeholder="large" :controls="false" />
<InputNumber v-model="withoutControls.xlarge" size="xlarge" placeholder="xlarge" :controls="false" />
</div>
<div style="width: 200px; display: flex; flex-direction: column; gap: var(--spacing--xs);">
<N8nInputLabel label="With controls (both)" />
<InputNumber v-model="controlsBoth.mini" size="mini" :controls="true" controls-position="both" />
<InputNumber v-model="controlsBoth.small" size="small" :controls="true" controls-position="both" />
<InputNumber v-model="controlsBoth.medium" size="medium" :controls="true" controls-position="both" />
<InputNumber v-model="controlsBoth.large" size="large" :controls="true" controls-position="both" />
<InputNumber v-model="controlsBoth.xlarge" size="xlarge" :controls="true" controls-position="both" />
</div>
<div style="width: 200px; display: flex; flex-direction: column; gap: var(--spacing--xs);">
<N8nInputLabel label="With controls (right)" />
<InputNumber v-model="controlsRight.mini" size="mini" :controls="true" controls-position="right" />
<InputNumber v-model="controlsRight.small" size="small" :controls="true" controls-position="right" />
<InputNumber v-model="controlsRight.medium" size="medium" :controls="true" controls-position="right" />
<InputNumber v-model="controlsRight.large" size="large" :controls="true" controls-position="right" />
<InputNumber v-model="controlsRight.xlarge" size="xlarge" :controls="true" controls-position="right" />
</div>
</div>
`,
}),
} 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: `
<div style="${storyPadding} display: flex; gap: var(--spacing--xl); flex-wrap: wrap;">
<div style="width: 200px; display: flex; flex-direction: column; gap: var(--spacing--xs);">
<N8nInputLabel label="Without controls" />
<InputNumber v-model="withoutControls.none" placeholder="No precision" :controls="false" />
<InputNumber v-model="withoutControls.two" :precision="2" placeholder="Precision: 2" :controls="false" />
<InputNumber v-model="withoutControls.one" :precision="1" placeholder="Precision: 1" :controls="false" />
<InputNumber v-model="withoutControls.zero" :precision="0" placeholder="Precision: 0" :controls="false" />
</div>
<div style="width: 200px; display: flex; flex-direction: column; gap: var(--spacing--xs);">
<N8nInputLabel label="With controls (both)" />
<InputNumber
v-model="controlsBoth.none"
:controls="true"
controls-position="both"
/>
<InputNumber
v-model="controlsBoth.two"
:precision="2"
:step="0.01"
:controls="true"
controls-position="both"
/>
<InputNumber
v-model="controlsBoth.one"
:precision="1"
:step="0.1"
:controls="true"
controls-position="both"
/>
<InputNumber
v-model="controlsBoth.zero"
:precision="0"
:controls="true"
controls-position="both"
/>
</div>
<div style="width: 200px; display: flex; flex-direction: column; gap: var(--spacing--xs);">
<N8nInputLabel label="With controls (right)" />
<InputNumber
v-model="controlsRight.none"
:controls="true"
controls-position="right"
/>
<InputNumber
v-model="controlsRight.two"
:precision="2"
:step="0.01"
:controls="true"
controls-position="right"
/>
<InputNumber
v-model="controlsRight.one"
:precision="1"
:step="0.1"
:controls="true"
controls-position="right"
/>
<InputNumber
v-model="controlsRight.zero"
:precision="0"
:controls="true"
controls-position="right"
/>
</div>
</div>
`,
}),
} 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: `
<div style="${storyStack}">
<N8nInputLabel label="Min = 0">
<InputNumber v-model="minValue" :min="0" :controls="true" controls-position="both" />
</N8nInputLabel>
<N8nInputLabel label="Max = 100">
<InputNumber v-model="maxValue" :max="100" :controls="true" controls-position="both" />
</N8nInputLabel>
</div>
`,
}),
} 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: `
<div style="${storyStack}">
<N8nInputLabel label="Without controls">
<InputNumber v-model="withoutControls" placeholder="Disabled" :disabled="true" :controls="false" />
</N8nInputLabel>
<N8nInputLabel label="With controls (both)">
<InputNumber
v-model="controlsBoth"
:controls="true"
controls-position="both"
:disabled="true"
/>
</N8nInputLabel>
<N8nInputLabel label="With controls (right)">
<InputNumber
v-model="controlsRight"
:controls="true"
controls-position="right"
:disabled="true"
/>
</N8nInputLabel>
</div>
`,
}),
} satisfies Story;
export const CustomButtons = {
render: (args) => ({
components: { InputNumber, N8nIcon },
setup() {
const value = ref(args.modelValue);
return { args, value };
},
template: `
<div style="${storyPadding} max-width: 320px;">
<InputNumber v-bind="args" v-model="value" :controls="true" controls-position="both">
<template #decrement="{ ui }">
<button type="button" :class="ui.class" aria-label="Decrease">
<N8nIcon icon="chevron-left" size="small" />
</button>
</template>
<template #increment="{ ui }">
<button type="button" :class="ui.class" aria-label="Increase">
<N8nIcon icon="chevron-right" size="small" />
</button>
</template>
</InputNumber>
</div>
`,
}),
args: {
modelValue: 5,
min: 1,
max: 99,
controls: true,
controlsPosition: 'both',
},
} satisfies Story;

View File

@ -1,320 +0,0 @@
<script setup lang="ts">
import { reactiveOmit, reactivePick } from '@vueuse/core';
import {
NumberFieldRoot,
NumberFieldInput,
NumberFieldIncrement,
NumberFieldDecrement,
useForwardPropsEmits,
} from 'reka-ui';
import { computed, useAttrs, useCssModule } from 'vue';
import Icon from '@n8n/design-system/components/N8nIcon/Icon.vue';
import { useI18n } from '@n8n/design-system/composables/useI18n';
import type { InputNumberProps, InputNumberEmits, InputNumberSlots } from './InputNumber.types';
defineOptions({ name: 'N8nInputNumber2', inheritAttrs: false });
const attrs = useAttrs();
const rootClass = computed(() => attrs.class);
const rootAttrs = computed(() => reactiveOmit(attrs, ['class']));
const $style = useCssModule();
const { t } = useI18n();
const props = withDefaults(defineProps<InputNumberProps>(), {
size: 'medium',
controls: true,
controlsPosition: 'right',
step: 1,
stepSnapping: false,
});
const isControlsRight = computed(() => props.controls && props.controlsPosition === 'right');
const isControlsBoth = computed(() => props.controls && props.controlsPosition === 'both');
const emit = defineEmits<InputNumberEmits>();
defineSlots<InputNumberSlots>();
// Map precision to formatOptions - uses Intl.NumberFormatOptions
// When no precision is set, use maximumFractionDigits: 20 (the max allowed by Intl.NumberFormat)
// to preserve full decimal precision and avoid default rounding behavior
const formatOptions = computed<Intl.NumberFormatOptions>(() =>
props.precision !== undefined
? { maximumFractionDigits: props.precision, minimumFractionDigits: props.precision }
: { maximumFractionDigits: 20 },
);
const rootProps = useForwardPropsEmits(
reactivePick(
props,
'modelValue',
'defaultValue',
'min',
'max',
'step',
'stepSnapping',
'disabled',
'readonly',
'disableWheelChange',
'invertWheelChange',
'id',
'name',
'required',
'locale',
),
emit,
);
function onFocus(event: FocusEvent) {
emit('focus', event);
}
/** Select all only on direct input click — not when controls focus the field. */
function onInputClick(event: MouseEvent) {
const target = event.target;
if (target instanceof HTMLInputElement) {
target.select();
}
}
const sizes: Record<NonNullable<InputNumberProps['size']>, string> = {
mini: $style.mini,
small: $style.small,
medium: $style.medium,
large: $style.large,
xlarge: $style.xlarge,
};
const sizeClass = computed(() => sizes[props.size ?? 'medium']);
</script>
<template>
<NumberFieldRoot
data-test-id="input-number"
v-bind="{ ...rootProps, ...rootAttrs, formatOptions }"
:class="[
$style.inputNumber,
sizeClass,
rootClass,
{
[$style.isDisabled]: props.disabled,
[$style.isControlsBoth]: isControlsBoth,
},
]"
>
<NumberFieldDecrement v-if="isControlsBoth" as-child>
<slot name="decrement" :ui="{ class: [$style.button, $style.buttonDecrement].join(' ') }">
<button
type="button"
:class="[$style.button, $style.buttonDecrement]"
:aria-label="t('nds.inputNumber.decrease')"
>
<Icon icon="minus" size="small" />
</button>
</slot>
</NumberFieldDecrement>
<NumberFieldInput
:class="$style.input"
:placeholder="placeholder"
@focus="onFocus"
@click="onInputClick"
@blur="emit('blur', $event)"
/>
<NumberFieldIncrement v-if="isControlsBoth" as-child>
<slot name="increment" :ui="{ class: [$style.button, $style.buttonIncrement].join(' ') }">
<button
type="button"
:class="[$style.button, $style.buttonIncrement]"
:aria-label="t('nds.inputNumber.increase')"
>
<Icon icon="plus" size="small" />
</button>
</slot>
</NumberFieldIncrement>
<div v-if="isControlsRight" :class="$style.controlsWrapper">
<NumberFieldIncrement as-child>
<slot name="increment" :ui="{ class: [$style.button, $style.buttonUp].join(' ') }">
<button
type="button"
:class="[$style.button, $style.buttonUp]"
:aria-label="t('nds.inputNumber.increase')"
>
<Icon icon="chevron-up" size="xsmall" />
</button>
</slot>
</NumberFieldIncrement>
<NumberFieldDecrement as-child>
<slot name="decrement" :ui="{ class: [$style.button, $style.buttonDown].join(' ') }">
<button
type="button"
:class="[$style.button, $style.buttonDown]"
:aria-label="t('nds.inputNumber.decrease')"
>
<Icon icon="chevron-down" size="xsmall" />
</button>
</slot>
</NumberFieldDecrement>
</div>
</NumberFieldRoot>
</template>
<style lang="scss" module>
@use '../../../css/mixins/focus';
@use '../../../css/mixins/input' as input-mixin;
.inputNumber {
@include input-mixin.size-variables('medium');
@include input-mixin.theme-variables(var(--border-color));
display: inline-flex;
width: 100%;
min-height: var(--input--height);
border-radius: var(--input--radius);
background-color: var(--input--color--background);
box-shadow:
var(--input--shadow),
inset var(--input--border--shadow);
font-size: var(--input--font-size);
color: var(--input--color--text);
@include focus.focus-within-ring;
&:hover:not(.isDisabled):not(:focus-within) {
box-shadow:
var(--input--shadow--hover),
inset var(--input--border--shadow--hover);
}
&:focus-within {
box-shadow:
var(--input--shadow--focus),
inset var(--input--border--shadow--focus);
}
}
.isDisabled {
cursor: not-allowed;
opacity: 0.6;
}
.input {
flex: 1;
min-width: 0;
min-height: var(--input--height);
border: none;
background: transparent;
outline: none;
font-size: var(--input--font-size);
color: var(--input--color--text);
padding: 0 var(--input--padding);
&::placeholder {
color: var(--input--placeholder--color);
}
&:disabled {
cursor: not-allowed;
color: var(--input--color--disabled);
}
}
.isControlsBoth .input {
text-align: center;
padding-inline: var(--spacing--3xs);
}
.button {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border: none;
background: transparent;
cursor: pointer;
color: var(--icon-color);
&:hover:not(:disabled) {
color: var(--icon-color--strong);
background-color: var(--background--hover);
}
&:disabled,
&[data-disabled] {
cursor: not-allowed;
color: var(--icon-color--subtle);
}
}
.buttonDecrement {
width: var(--input--height);
border-right: var(--border-width, 1px) solid var(--input--border-color);
border-radius: var(--input--radius) 0 0 var(--input--radius);
&:hover:not(:disabled):not([data-disabled]) {
border-right-color: var(--input--border-color--hover);
}
}
.buttonIncrement {
width: var(--input--height);
border-left: var(--border-width, 1px) solid var(--input--border-color);
border-radius: 0 var(--input--radius) var(--input--radius) 0;
&:hover:not(:disabled):not([data-disabled]) {
border-left-color: var(--input--border-color--hover);
}
}
.controlsWrapper {
display: flex;
flex-direction: column;
border-left: var(--border-width, 1px) solid var(--input--border-color);
&:hover:has(button:not(:disabled):not([data-disabled])) {
border-left-color: var(--input--border-color--hover);
.buttonUp {
border-bottom-color: var(--input--border-color--hover);
}
}
}
.buttonUp,
.buttonDown {
flex: 1;
}
.buttonUp {
border-bottom: var(--border-width, 1px) solid var(--input--border-color);
border-radius: 0 var(--input--radius) 0 0;
}
.buttonDown {
border-radius: 0 0 var(--input--radius) 0;
}
.mini {
@include input-mixin.size-variables('mini');
}
.small {
@include input-mixin.size-variables('small');
}
.medium {
@include input-mixin.size-variables('medium');
}
.large {
@include input-mixin.size-variables('large');
}
.xlarge {
@include input-mixin.size-variables('xlarge');
}
</style>

View File

@ -1,2 +0,0 @@
export { default as N8nInputNumber2 } from './InputNumber.vue';
export type * from './InputNumber.types';

View File

@ -51,7 +51,7 @@ vi.mock('@vueuse/core', async (importOriginal) => {
const globalStubs = {
N8nIcon: { template: '<span v-bind="$attrs" />', props: ['icon', 'size'] },
N8nText: { template: '<span><slot /></span>' },
N8nInputNumber2: {
N8nInputNumber: {
props: ['modelValue', 'disabled', 'min', 'max', 'precision', 'placeholder'],
emits: ['update:modelValue'],
template:

View File

@ -144,7 +144,7 @@ vi.mock('@n8n/design-system', () => ({
template: '<button :disabled="disabled" v-bind="$attrs"><slot /></button>',
props: ['disabled', 'ariaLabel', 'icon', 'variant', 'size', 'iconSize'],
},
N8nInputNumber2: {
N8nInputNumber: {
props: ['modelValue', 'disabled', 'min', 'max', 'precision'],
emits: ['update:modelValue'],
template:

View File

@ -9,7 +9,7 @@ import { useDebounceFn } from '@vueuse/core';
import { AGENT_REASONING_LEVELS, type AgentReasoningLevel } from '@n8n/api-types';
import {
N8nIcon,
N8nInputNumber2,
N8nInputNumber,
N8nOption,
N8nSelect,
N8nSwitch2,
@ -115,7 +115,7 @@ type NumberFieldOptions =
/**
* Creates a ref, debounced config-emit, change handler, and watch-sync
* function for one numeric field inside `config`. Designed for N8nInputNumber2
* function for one numeric field inside `config`. Designed for N8nInputNumber
* which emits numbers directly (NaN when the field is cleared).
*
* Pass a number for fields that always persist their fallback (e.g. concurrency).
@ -468,7 +468,7 @@ function onAnthropicTtlChange(value: AnthropicCacheTtl) {
{{ i18n.baseText('agents.builder.advanced.webSearch.maxUses.hint') }}
</N8nText>
</div>
<N8nInputNumber2
<N8nInputNumber
:model-value="Number(webSearchMaxUses)"
:min="1"
:precision="0"
@ -656,7 +656,7 @@ function onAnthropicTtlChange(value: AnthropicCacheTtl) {
{{ i18n.baseText('agents.builder.advanced.concurrency.hint') }}
</N8nText>
</div>
<N8nInputNumber2
<N8nInputNumber
:model-value="concurrencyModelValue"
:min="CONCURRENCY_MIN"
:max="CONCURRENCY_MAX"
@ -678,7 +678,7 @@ function onAnthropicTtlChange(value: AnthropicCacheTtl) {
{{ i18n.baseText('agents.builder.advanced.maxIterations.hint') }}
</N8nText>
</div>
<N8nInputNumber2
<N8nInputNumber
:model-value="maxIterationsModelValue"
:min="MAX_ITERATIONS_MIN"
:max="MAX_ITERATIONS_MAX"

View File

@ -8,13 +8,7 @@ import {
type SubAgentTaskDifficulty,
} from '@n8n/api-types';
import type { BaseTextKey } from '@n8n/i18n';
import {
N8nIconButton,
N8nInputNumber2,
N8nSwitch2,
N8nText,
N8nTooltip,
} from '@n8n/design-system';
import { N8nIconButton, N8nInputNumber, N8nSwitch2, N8nText, N8nTooltip } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { useToast } from '@n8n/composables/useToast';
import { useUsersStore } from '@n8n/stores/users.store';
@ -331,7 +325,7 @@ function clearDifficultyMapping(difficulty: SubAgentTaskDifficulty) {
}}
</N8nText>
</div>
<N8nInputNumber2
<N8nInputNumber
:model-value="maxChildrenModelValue"
:min="SUB_AGENT_MAX_CHILDREN_MIN"
:max="SUB_AGENT_MAX_CHILDREN_MAX"

View File

@ -165,7 +165,7 @@ describe('FilterConditions.vue', () => {
);
expect(
within(conditions[3]).getByTestId('filter-condition-right').querySelector('input'),
).toHaveValue(5);
).toHaveValue('5');
});
it('renders parameter issues', async () => {
@ -473,13 +473,13 @@ describe('FilterConditions.vue', () => {
).toHaveValue('qux');
expect(
within(conditions[1]).getByTestId('filter-condition-left').querySelector('input'),
).toHaveValue(5);
).toHaveValue('5');
expect(within(conditions[1]).getByTestId('filter-operator-select')).toHaveTextContent(
'is greater than',
);
expect(
within(conditions[1]).getByTestId('filter-condition-right').querySelector('input'),
).toHaveValue(6);
).toHaveValue('6');
});
it('emits once with updated caseSensitive when typeOptions.filter.caseSensitive changes', async () => {

View File

@ -1231,9 +1231,9 @@ function onJsonPasswordFieldChange(value: string) {
onUpdateTextInputDebounced(value);
}
function onUpdateTextInput(value: string) {
function onUpdateTextInput(value: string | number) {
valueChanged(value);
onTextInputChange(value);
onTextInputChange(typeof value === 'string' ? value : String(value));
}
const onUpdateTextInputDebounced = debounce(onUpdateTextInput, { debounceTime: 200 });
@ -1964,7 +1964,7 @@ onUpdated(async () => {
v-else-if="parameter.type === 'number'"
ref="inputField"
:size="inputSize"
:model-value="displayValue"
:model-value="typeof displayValue === 'number' ? displayValue : undefined"
:controls="false"
:max="getTypeOption('maxValue')"
:min="getTypeOption('minValue')"

View File

@ -23,6 +23,7 @@ const canSelectNextItem = computed(
);
const inputCharWidth = computed(() => itemIndex.value.toString().length);
const inputWidth = computed(() => `calc(${inputCharWidth.value}ch + var(--spacing--sm))`);
function updateItemIndex(index: number) {
ndvStore.value.expressionOutputItemIndex = index;
@ -52,7 +53,7 @@ function prevItem() {
:min="0"
:max="max"
:model-value="itemIndex"
:style="{ '--output-item-select--width': `calc(${inputCharWidth}ch + var(--spacing--sm))` }"
:style="{ width: inputWidth, maxWidth: inputWidth, minWidth: inputWidth }"
@update:model-value="updateItemIndex"
></N8nInputNumber>
<N8nIconButton
@ -85,12 +86,14 @@ function prevItem() {
.item {
display: flex;
align-items: center;
flex-shrink: 0;
gap: var(--spacing--4xs);
}
.controls {
display: flex;
align-items: center;
flex-shrink: 0;
}
.controls .input {
@ -99,19 +102,17 @@ function prevItem() {
--input--radius--bottom-left: var(--radius);
--input-triple--radius--top-right: var(--radius);
--input-triple--radius--bottom-right: var(--radius);
--input--padding: var(--spacing--4xs);
flex: 0 0 auto;
box-sizing: border-box;
line-height: calc(var(--input--height) - var(--spacing--4xs));
&.hovering {
--input--color--text: var(--color--secondary);
}
:global(.el-input__inner) {
height: var(--input--height);
min-height: var(--input--height);
line-height: var(--input--height);
:global(input) {
text-align: center;
padding: 0 var(--spacing--4xs);
max-width: var(--output-item-select--width);
}
}
</style>

View File

@ -609,10 +609,10 @@ describe('WorkflowSettingsVue', () => {
const timeSavedPerExecutionInput = getByTestId(
'workflow-settings-time-saved-per-execution',
)?.querySelector('input[type="number"]');
)?.querySelector('input');
await userEvent.type(timeSavedPerExecutionInput as Element, '10');
expect(timeSavedPerExecutionInput).toHaveValue(10);
expect(timeSavedPerExecutionInput).toHaveValue('10');
await userEvent.click(getByRole('button', { name: 'Save' }));
expect(workflowsStore.updateWorkflow).toHaveBeenCalledWith(
@ -632,8 +632,8 @@ describe('WorkflowSettingsVue', () => {
const timeSavedPerExecutionInput = getByTestId(
'workflow-settings-time-saved-per-execution',
)?.querySelector('input[type="number"]');
await waitFor(() => expect(timeSavedPerExecutionInput).toHaveValue(10));
)?.querySelector('input');
await waitFor(() => expect(timeSavedPerExecutionInput).toHaveValue('10'));
await userEvent.clear(timeSavedPerExecutionInput as Element);
expect(timeSavedPerExecutionInput).not.toHaveValue();
@ -659,7 +659,7 @@ describe('WorkflowSettingsVue', () => {
const timeSavedPerExecutionInput = getByTestId(
'workflow-settings-time-saved-per-execution',
)?.querySelector('input[type="number"]');
)?.querySelector('input');
expect(timeSavedPerExecutionInput).toBeDisabled();
});
@ -684,7 +684,7 @@ describe('WorkflowSettingsVue', () => {
const timeSavedPerExecutionInput = getByTestId(
'workflow-settings-time-saved-per-execution',
)?.querySelector('input[type="number"]');
)?.querySelector('input');
expect(timeSavedPerExecutionInput).toBeDisabled();
});

View File

@ -823,9 +823,9 @@ const toggleAvailableInMCP = () => {
workflowSettings.value.availableInMCP = !workflowSettings.value.availableInMCP;
};
const updateTimeSavedPerExecution = (value: string) => {
const numValue = parseInt(value, 10);
workflowSettings.value.timeSavedPerExecution = isNaN(numValue)
const updateTimeSavedPerExecution = (value: number | null | undefined) => {
const numValue = typeof value === 'number' && Number.isFinite(value) ? Math.trunc(value) : NaN;
workflowSettings.value.timeSavedPerExecution = Number.isNaN(numValue)
? undefined
: numValue < 0
? 0
@ -1719,6 +1719,7 @@ onBeforeUnmount(() => {
controls-position="right"
size="medium"
:controls="true"
:class="$style.timeSavedPerExecution"
:disabled="readOnlyEnv || !workflowPermissions.update"
data-test-id="workflow-settings-time-saved-per-execution"
:min="0"
@ -1900,10 +1901,11 @@ onBeforeUnmount(() => {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
}
:global(.el-input-number) {
width: var(--spacing--4xl);
}
.timeSavedPerExecution {
width: var(--spacing--4xl);
flex-shrink: 0;
}
.time-saved-dropdown {

View File

@ -117,6 +117,10 @@ export class ScheduleTrigger implements INodeType {
field: ['seconds'],
},
},
typeOptions: {
minValue: 1,
maxValue: 59,
},
description: 'Number of seconds between each workflow trigger',
hint: 'Must be in range 1-59',
},
@ -130,6 +134,10 @@ export class ScheduleTrigger implements INodeType {
field: ['minutes'],
},
},
typeOptions: {
minValue: 1,
maxValue: 59,
},
description: 'Number of minutes between each workflow trigger',
hint: 'Must be in range 1-59',
},
@ -142,6 +150,10 @@ export class ScheduleTrigger implements INodeType {
field: ['hours'],
},
},
typeOptions: {
minValue: 1,
maxValue: 23,
},
default: 1,
description: 'Number of hours between each workflow trigger',
hint: 'Must be in range 1-23',
@ -155,6 +167,10 @@ export class ScheduleTrigger implements INodeType {
field: ['days'],
},
},
typeOptions: {
minValue: 1,
maxValue: 31,
},
default: 1,
description: 'Number of days between each workflow trigger',
hint: 'Must be in range 1-31',