mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 13:39:44 +02:00
feat(editor): Complete N8nInputNumber2 adoption and remove the legacy InputNumber (#36598)
This commit is contained in:
parent
5c7e0062cb
commit
73ac8e43c8
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
```
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import N8nInputNumber from './InputNumber.vue';
|
||||
|
||||
export default N8nInputNumber;
|
||||
export { N8nInputNumber };
|
||||
export type * from './InputNumber.types';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
-------------------------- */
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export { default as N8nInputNumber2 } from './InputNumber.vue';
|
||||
export type * from './InputNumber.types';
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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')"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user