mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-21 20:20:03 +02:00
feat(editor): Add and adopt the shared slider (no-changelog) (#41381)
Co-authored-by: Bob Hough <15361982+heymynameisrob@users.noreply.github.com> Co-authored-by: linear-code[bot] <222613912+linear-code[bot]@users.noreply.github.com>
This commit is contained in:
parent
990676c35b
commit
1cafc2e121
|
|
@ -0,0 +1,115 @@
|
|||
import type { Meta, StoryObj } from '@storybook/vue3-vite';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import type { SliderValue } from './Slider.types';
|
||||
import Slider from './Slider.vue';
|
||||
|
||||
const meta = {
|
||||
title: 'Core/Slider',
|
||||
component: Slider,
|
||||
argTypes: {
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['mini', 'small', 'medium', 'large', 'xlarge'],
|
||||
},
|
||||
},
|
||||
args: {
|
||||
label: 'Volume',
|
||||
defaultValue: [50],
|
||||
size: 'large',
|
||||
},
|
||||
} satisfies Meta<typeof Slider>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const Mini: Story = { args: { size: 'mini' } };
|
||||
export const Small: Story = { args: { size: 'small' } };
|
||||
export const Medium: Story = { args: { size: 'medium' } };
|
||||
export const Large: Story = { args: { size: 'large' } };
|
||||
export const Xlarge: Story = { args: { size: 'xlarge' } };
|
||||
|
||||
export const Controlled: Story = {
|
||||
args: {
|
||||
defaultValue: [20],
|
||||
},
|
||||
render: (args) => ({
|
||||
components: { Slider },
|
||||
setup() {
|
||||
const value = ref<SliderValue>(args.defaultValue ?? [20]);
|
||||
return { args, value };
|
||||
},
|
||||
template: '<Slider v-bind="args" v-model="value" />',
|
||||
}),
|
||||
};
|
||||
|
||||
export const StepMarkers: Story = {
|
||||
args: {
|
||||
label: 'Volume',
|
||||
defaultValue: [50],
|
||||
showStepMarkers: true,
|
||||
step: 10,
|
||||
},
|
||||
};
|
||||
|
||||
export const NonZeroMinimum: Story = {
|
||||
args: {
|
||||
minValue: 20,
|
||||
maxValue: 120,
|
||||
defaultValue: [70],
|
||||
step: 10,
|
||||
showStepMarkers: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Formatted: Story = {
|
||||
args: {
|
||||
label: 'Opacity',
|
||||
minValue: 0,
|
||||
maxValue: 1,
|
||||
step: 0.1,
|
||||
defaultValue: [0.5],
|
||||
formatValue: (value) => `${Math.round(value * 100)}%`,
|
||||
},
|
||||
};
|
||||
|
||||
export const HiddenLabel: Story = {
|
||||
args: { hideLabel: true },
|
||||
};
|
||||
|
||||
export const HiddenValue: Story = {
|
||||
args: { hideValue: true },
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: { disabled: true },
|
||||
};
|
||||
|
||||
export const CustomLabel: Story = {
|
||||
render: (args) => ({
|
||||
components: { Slider },
|
||||
setup() {
|
||||
return { args };
|
||||
},
|
||||
template:
|
||||
'<Slider v-bind="args"><template #label><strong>{{ args.label }}</strong></template></Slider>',
|
||||
}),
|
||||
};
|
||||
|
||||
export const AccessibleLabelOnly: Story = {
|
||||
args: { label: undefined, ariaLabel: 'Volume', hideValue: true },
|
||||
};
|
||||
|
||||
export const ExternalLabel: Story = {
|
||||
args: { label: undefined, ariaLabelledby: 'external-volume-label' },
|
||||
render: (args) => ({
|
||||
components: { Slider },
|
||||
setup() {
|
||||
return { args };
|
||||
},
|
||||
template: '<div><span id="external-volume-label">Volume</span><Slider v-bind="args" /></div>',
|
||||
}),
|
||||
};
|
||||
|
|
@ -0,0 +1,292 @@
|
|||
import { fireEvent, render } from '@testing-library/vue';
|
||||
import { defineComponent, ref } from 'vue';
|
||||
|
||||
import type { SliderProps } from './Slider.types';
|
||||
import Slider from './Slider.vue';
|
||||
import type { InputSize } from '../../types/input';
|
||||
|
||||
describe('components/N8nSlider', () => {
|
||||
it('uses the large size by default', () => {
|
||||
const wrapper = render(Slider, { props: { label: 'Volume' } });
|
||||
|
||||
expect(wrapper.container.firstElementChild).toHaveClass('large');
|
||||
});
|
||||
|
||||
it.each<InputSize>(['mini', 'small', 'medium', 'large', 'xlarge'])(
|
||||
'applies the %s size',
|
||||
(size) => {
|
||||
const wrapper = render(Slider, { props: { label: 'Volume', size } });
|
||||
|
||||
expect(wrapper.container.firstElementChild).toHaveClass(size);
|
||||
expect(wrapper.container.firstElementChild).not.toHaveAttribute('size');
|
||||
},
|
||||
);
|
||||
|
||||
it('renders the supplied label', () => {
|
||||
const wrapper = render(Slider, { props: { label: 'Volume' } });
|
||||
|
||||
expect(wrapper.getByText('Volume')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the visible label when requested', () => {
|
||||
const wrapper = render(Slider, { props: { label: 'Volume', hideLabel: true } });
|
||||
|
||||
const label = wrapper.getByText('Volume');
|
||||
|
||||
expect(label).toHaveClass('hiddenLabel');
|
||||
expect(label).not.toHaveAttribute('aria-hidden');
|
||||
expect(label).not.toHaveAttribute('hidden');
|
||||
expect(wrapper.getByRole('slider', { name: 'Volume' })).toHaveAttribute(
|
||||
'aria-labelledby',
|
||||
label.id,
|
||||
);
|
||||
});
|
||||
|
||||
it('uses custom label content as the accessible name', () => {
|
||||
const wrapper = render(Slider, {
|
||||
props: { label: 'Fallback' },
|
||||
slots: { label: '<span>Custom volume</span>' },
|
||||
});
|
||||
|
||||
expect(wrapper.getByRole('slider', { name: 'Custom volume' })).toBeInTheDocument();
|
||||
expect(wrapper.queryByText('Fallback')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each<SliderProps>([{}, { label: ' ' }, { ariaLabel: ' ' }, { ariaLabelledby: ' ' }])(
|
||||
'updates the accessible name after mounting without a naming source: %j',
|
||||
async (props) => {
|
||||
const wrapper = render(Slider, { props });
|
||||
const control = wrapper.getByRole('slider');
|
||||
|
||||
expect(control).not.toHaveAccessibleName();
|
||||
await wrapper.rerender({ label: 'Volume', ariaLabel: undefined, ariaLabelledby: undefined });
|
||||
|
||||
expect(wrapper.getByRole('slider', { name: 'Volume' })).toBe(control);
|
||||
},
|
||||
);
|
||||
|
||||
it('uses a label slot without a label prop', () => {
|
||||
const wrapper = render(Slider, { slots: { label: '<span>Volume</span>' } });
|
||||
|
||||
expect(wrapper.getByRole('slider', { name: 'Volume' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates the label and naming reference when a label slot changes', async () => {
|
||||
const showLabel = ref(false);
|
||||
const parent = defineComponent({
|
||||
components: { Slider },
|
||||
setup() {
|
||||
return { showLabel };
|
||||
},
|
||||
template: `
|
||||
<button @click="showLabel = !showLabel">Toggle label</button>
|
||||
<Slider :aria-label="showLabel ? undefined : 'Volume'">
|
||||
<template v-if="showLabel" #label>Custom volume</template>
|
||||
</Slider>
|
||||
`,
|
||||
});
|
||||
const wrapper = render(parent);
|
||||
const control = wrapper.getByRole('slider', { name: 'Volume' });
|
||||
|
||||
expect(wrapper.queryByText('Custom volume')).not.toBeInTheDocument();
|
||||
await fireEvent.click(wrapper.getByRole('button'));
|
||||
expect(wrapper.getByRole('slider', { name: 'Custom volume' })).toHaveAttribute(
|
||||
'aria-labelledby',
|
||||
wrapper.getByText('Custom volume').id,
|
||||
);
|
||||
await fireEvent.click(wrapper.getByRole('button'));
|
||||
expect(wrapper.queryByText('Custom volume')).not.toBeInTheDocument();
|
||||
expect(control).not.toHaveAttribute('aria-labelledby');
|
||||
expect(wrapper.getByRole('slider', { name: 'Volume' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows markers by default and can disable them', async () => {
|
||||
const wrapper = render(Slider, { props: { label: 'Volume' } });
|
||||
|
||||
expect(wrapper.container.querySelectorAll('.hashmark')).toHaveLength(9);
|
||||
await wrapper.rerender({ showStepMarkers: false });
|
||||
expect(wrapper.container.querySelectorAll('.hashmark')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('applies aria-label to the slider control', () => {
|
||||
const wrapper = render(Slider, { props: { ariaLabel: 'Volume', hideValue: true } });
|
||||
const control = wrapper.getByRole('slider', { name: 'Volume' });
|
||||
|
||||
expect(control).toHaveAttribute('aria-label', 'Volume');
|
||||
expect(control).not.toHaveAttribute('aria-labelledby');
|
||||
expect(wrapper.container.firstElementChild).not.toHaveAttribute('aria-label');
|
||||
});
|
||||
|
||||
it('uses an external label before other accessible names', () => {
|
||||
const wrapper = render(Slider, {
|
||||
props: { label: 'Internal', ariaLabel: 'Other', ariaLabelledby: 'external-label' },
|
||||
});
|
||||
const externalLabel = document.createElement('span');
|
||||
externalLabel.id = 'external-label';
|
||||
externalLabel.textContent = 'External volume';
|
||||
wrapper.container.prepend(externalLabel);
|
||||
|
||||
expect(wrapper.getByRole('slider', { name: 'External volume' })).toHaveAttribute(
|
||||
'aria-labelledby',
|
||||
'external-label',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the label and value outside the track', async () => {
|
||||
const wrapper = render(Slider, { props: { label: 'Volume', defaultValue: [50] } });
|
||||
const control = wrapper.getByRole('slider');
|
||||
const label = wrapper.getByText('Volume');
|
||||
const visibleValue = wrapper.getByText('50');
|
||||
|
||||
expect(control.parentElement).not.toContainElement(label);
|
||||
expect(control.parentElement).not.toContainElement(visibleValue);
|
||||
await fireEvent.pointerDown(label, { pointerId: 1, button: 0, clientX: 50 });
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined();
|
||||
expect(wrapper.emitted('valueCommit')).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['ArrowUp', 22],
|
||||
['ArrowRight', 22],
|
||||
['ArrowDown', 18],
|
||||
['ArrowLeft', 18],
|
||||
['Home', 10],
|
||||
['End', 30],
|
||||
['PageUp', 30],
|
||||
['PageDown', 10],
|
||||
])('changes the value with %s', async (key, expected) => {
|
||||
const wrapper = render(Slider, {
|
||||
props: { label: 'Volume', defaultValue: [20], minValue: 10, maxValue: 30, step: 2 },
|
||||
});
|
||||
const control = wrapper.getByRole('slider');
|
||||
control.focus();
|
||||
|
||||
await fireEvent.keyDown(control, { key });
|
||||
|
||||
expect(control).toHaveAttribute('aria-valuenow', String(expected));
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[[expected]]]);
|
||||
expect(wrapper.emitted('valueCommit')).toEqual([[[expected]]]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['ArrowUp', 60],
|
||||
['ArrowRight', 60],
|
||||
['ArrowDown', 20],
|
||||
['ArrowLeft', 20],
|
||||
])('uses ten steps for Shift + %s', async (key, expected) => {
|
||||
const wrapper = render(Slider, {
|
||||
props: { label: 'Volume', defaultValue: [40], step: 2 },
|
||||
});
|
||||
const control = wrapper.getByRole('slider');
|
||||
|
||||
await fireEvent.keyDown(control, { key, shiftKey: true });
|
||||
|
||||
expect(control).toHaveAttribute('aria-valuenow', String(expected));
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[[expected]]]);
|
||||
expect(wrapper.emitted('valueCommit')).toEqual([[[expected]]]);
|
||||
});
|
||||
|
||||
it('uses ten steps for page keys', async () => {
|
||||
const wrapper = render(Slider, {
|
||||
props: { label: 'Volume', defaultValue: [40], step: 2 },
|
||||
});
|
||||
const control = wrapper.getByRole('slider');
|
||||
|
||||
await fireEvent.keyDown(control, { key: 'PageUp' });
|
||||
expect(control).toHaveAttribute('aria-valuenow', '60');
|
||||
await fireEvent.keyDown(control, { key: 'PageDown' });
|
||||
expect(control).toHaveAttribute('aria-valuenow', '40');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['ArrowUp', 100],
|
||||
['ArrowDown', 0],
|
||||
])('does not emit at the limit for %s', async (key, defaultValue) => {
|
||||
const wrapper = render(Slider, { props: { label: 'Volume', defaultValue: [defaultValue] } });
|
||||
|
||||
await fireEvent.keyDown(wrapper.getByRole('slider'), { key });
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined();
|
||||
expect(wrapper.emitted('valueCommit')).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([50, 80])('commits the pointer value %s on release', async (pointerValue) => {
|
||||
const wrapper = render(Slider, { props: { label: 'Volume', defaultValue: [50] } });
|
||||
const control = wrapper.getByRole('slider');
|
||||
vi.spyOn(control, 'getBoundingClientRect').mockReturnValue({
|
||||
x: 0,
|
||||
y: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 100,
|
||||
bottom: 40,
|
||||
width: 100,
|
||||
height: 40,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
control.setPointerCapture = vi.fn();
|
||||
control.hasPointerCapture = vi.fn(() => true);
|
||||
control.releasePointerCapture = vi.fn();
|
||||
|
||||
await fireEvent.pointerDown(control, { pointerId: 1, button: 0, clientX: 50 });
|
||||
|
||||
expect(control).toHaveAttribute('aria-valuenow', '50');
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined();
|
||||
expect(wrapper.emitted('valueCommit')).toBeUndefined();
|
||||
|
||||
await fireEvent.pointerMove(control, { pointerId: 1, buttons: 1, clientX: pointerValue });
|
||||
|
||||
expect(control).toHaveAttribute('aria-valuenow', String(pointerValue));
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual(
|
||||
pointerValue === 50 ? undefined : [[[pointerValue]]],
|
||||
);
|
||||
expect(wrapper.emitted('valueCommit')).toBeUndefined();
|
||||
|
||||
await fireEvent.pointerUp(control, { pointerId: 1, button: 0, clientX: pointerValue });
|
||||
|
||||
expect(control).toHaveAttribute('aria-valuenow', String(pointerValue));
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual(
|
||||
pointerValue === 50 ? undefined : [[[pointerValue]]],
|
||||
);
|
||||
expect(wrapper.emitted('valueCommit')).toEqual([[[pointerValue]]]);
|
||||
expect(control.releasePointerCapture).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('ignores keys when disabled', async () => {
|
||||
const wrapper = render(Slider, { props: { label: 'Volume', disabled: true } });
|
||||
|
||||
const control = wrapper.getByRole('slider');
|
||||
|
||||
expect(control.parentElement).toHaveClass('disabled');
|
||||
expect(control).toHaveAttribute('aria-disabled', 'true');
|
||||
expect(control).toHaveAttribute('tabindex', '-1');
|
||||
|
||||
await fireEvent.keyDown(control, { key: 'ArrowUp' });
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined();
|
||||
expect(wrapper.emitted('valueCommit')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('prevents scrolling only for handled keys', async () => {
|
||||
const wrapper = render(Slider, { props: { label: 'Volume' } });
|
||||
const control = wrapper.getByRole('slider');
|
||||
const arrowEvent = new KeyboardEvent('keydown', { key: 'ArrowUp', cancelable: true });
|
||||
const tabEvent = new KeyboardEvent('keydown', { key: 'Tab', cancelable: true });
|
||||
|
||||
await fireEvent(control, arrowEvent);
|
||||
await fireEvent(control, tabEvent);
|
||||
|
||||
expect(arrowEvent.defaultPrevented).toBe(true);
|
||||
expect(tabEvent.defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it('does not emit value events on render', () => {
|
||||
const wrapper = render(Slider, {
|
||||
props: { label: 'Price', modelValue: [20] },
|
||||
});
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined();
|
||||
expect(wrapper.emitted('valueCommit')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import type { InputSize } from '../../types/input';
|
||||
|
||||
export type SliderValue = [number];
|
||||
|
||||
export interface SliderProps {
|
||||
/** The control size. Matches N8nInput. Default large. */
|
||||
size?: InputSize;
|
||||
/** The value in controlled mode. */
|
||||
modelValue?: SliderValue;
|
||||
/** The initial value in uncontrolled mode. */
|
||||
defaultValue?: SliderValue;
|
||||
/** The maximum allowed value. Default 100. */
|
||||
maxValue?: number;
|
||||
/** The minimum allowed value. Default 0. */
|
||||
minValue?: number;
|
||||
/** The step interval. Default 1. */
|
||||
step?: number;
|
||||
/** The minimum difference between range values. Default 0. */
|
||||
minDistance?: number;
|
||||
/** The visible label and default accessible name. Supply this, a label slot, or an ARIA name. */
|
||||
label?: string;
|
||||
/** Names the slider when there is no visible label. */
|
||||
ariaLabel?: string;
|
||||
/** References an external label. Takes priority over other accessible names. */
|
||||
ariaLabelledby?: string;
|
||||
/** Hides the visible label. Default false. */
|
||||
hideLabel?: boolean;
|
||||
/** Enables step markers. Default true. */
|
||||
showStepMarkers?: boolean;
|
||||
/** Disables user input. Default false. */
|
||||
disabled?: boolean;
|
||||
/** Formats visible and accessible value text. */
|
||||
formatValue?: (value: number) => string;
|
||||
/** Hides the visible value. Default false. */
|
||||
hideValue?: boolean;
|
||||
}
|
||||
|
||||
export interface SliderEmits {
|
||||
/** Emits when user input changes the value. */
|
||||
'update:modelValue': [value: SliderValue];
|
||||
/** Emits when a user interaction ends. */
|
||||
valueCommit: [value: SliderValue];
|
||||
}
|
||||
|
|
@ -0,0 +1,429 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref, useCssModule, useId, useSlots, useTemplateRef } from 'vue';
|
||||
import N8nText from '../N8nText';
|
||||
|
||||
import type { SliderEmits, SliderProps, SliderValue } from './Slider.types';
|
||||
|
||||
const props = withDefaults(defineProps<SliderProps>(), {
|
||||
size: 'large',
|
||||
maxValue: 100,
|
||||
minValue: 0,
|
||||
step: 1,
|
||||
minDistance: 0,
|
||||
hideLabel: false,
|
||||
showStepMarkers: true,
|
||||
disabled: false,
|
||||
hideValue: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<SliderEmits>();
|
||||
const styles = useCssModule();
|
||||
const labelId = useId();
|
||||
const slots = useSlots();
|
||||
|
||||
defineSlots<{
|
||||
/** Replaces the visible label text. */
|
||||
label?: () => unknown;
|
||||
}>();
|
||||
|
||||
function hasLabel() {
|
||||
return Boolean(props.label?.trim() || slots.label);
|
||||
}
|
||||
|
||||
function getAccessibleLabelledBy() {
|
||||
return (
|
||||
props.ariaLabelledby?.trim() || (!props.ariaLabel?.trim() && hasLabel() ? labelId : undefined)
|
||||
);
|
||||
}
|
||||
|
||||
const isDragging = ref(false);
|
||||
const animateFill = ref(false);
|
||||
const control = useTemplateRef<HTMLDivElement>('control');
|
||||
const localValue = ref(props.defaultValue?.[0] ?? props.minValue);
|
||||
const value = computed(() => props.modelValue?.[0] ?? localValue.value);
|
||||
let dragPointerId: number | undefined;
|
||||
let dragValue = 0;
|
||||
|
||||
const sizeClass = computed(() => styles[props.size]);
|
||||
|
||||
const fontSize = computed(() => {
|
||||
switch (props.size) {
|
||||
case 'mini':
|
||||
return '2xs';
|
||||
case 'xlarge':
|
||||
return 'sm';
|
||||
default:
|
||||
return 'xs';
|
||||
}
|
||||
});
|
||||
|
||||
const formattedValue = computed(() => props.formatValue?.(value.value) ?? value.value);
|
||||
|
||||
const markers = computed(() =>
|
||||
props.showStepMarkers && props.maxValue > props.minValue
|
||||
? [10, 20, 30, 40, 50, 60, 70, 80, 90]
|
||||
: [],
|
||||
);
|
||||
|
||||
const fillStyle = computed(() => {
|
||||
const range = props.maxValue - props.minValue;
|
||||
const percentage = range > 0 ? ((value.value - props.minValue) / range) * 100 : 0;
|
||||
return { width: `${Math.min(100, Math.max(0, percentage))}%` };
|
||||
});
|
||||
|
||||
/** Allows slider to be controllable with key events whilst focused */
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (props.disabled || isDragging.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const step = props.step > 0 ? props.step : 1;
|
||||
const arrowStep = event.shiftKey ? step * 10 : step;
|
||||
let nextValue: number;
|
||||
switch (event.key) {
|
||||
case 'ArrowUp':
|
||||
case 'ArrowRight':
|
||||
nextValue = value.value + arrowStep;
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
case 'ArrowLeft':
|
||||
nextValue = value.value - arrowStep;
|
||||
break;
|
||||
case 'Home':
|
||||
nextValue = props.minValue;
|
||||
break;
|
||||
case 'End':
|
||||
nextValue = props.maxValue;
|
||||
break;
|
||||
case 'PageUp':
|
||||
nextValue = value.value + step * 10;
|
||||
break;
|
||||
case 'PageDown':
|
||||
nextValue = value.value - step * 10;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
const clampedValue = Math.min(
|
||||
props.maxValue,
|
||||
Math.max(props.minValue, Number(nextValue.toPrecision(15))),
|
||||
);
|
||||
if (clampedValue === value.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
localValue.value = clampedValue;
|
||||
emit('update:modelValue', [clampedValue]);
|
||||
emit('valueCommit', [clampedValue]);
|
||||
}
|
||||
|
||||
function handleDrag(event: PointerEvent) {
|
||||
if (props.disabled || !isDragging.value || event.pointerId !== dragPointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === 'pointermove') {
|
||||
animateFill.value = false;
|
||||
}
|
||||
|
||||
const bounds = control.value?.getBoundingClientRect();
|
||||
if (!bounds || bounds.width <= 0 || props.maxValue <= props.minValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextValue =
|
||||
props.minValue +
|
||||
((event.clientX - bounds.left) / bounds.width) * (props.maxValue - props.minValue);
|
||||
const step = props.step ?? 1;
|
||||
const steppedValue =
|
||||
step > 0 ? props.minValue + Math.round((nextValue - props.minValue) / step) * step : nextValue;
|
||||
const clampedValue = Math.min(
|
||||
props.maxValue,
|
||||
Math.max(props.minValue, Number(steppedValue.toPrecision(15))),
|
||||
);
|
||||
if (clampedValue === dragValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
dragValue = clampedValue;
|
||||
localValue.value = clampedValue;
|
||||
const nextModelValue: SliderValue = [clampedValue];
|
||||
emit('update:modelValue', nextModelValue);
|
||||
}
|
||||
|
||||
function handleDragStart(event: PointerEvent) {
|
||||
if (props.disabled || isDragging.value || event.button !== 0 || !control.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
dragPointerId = event.pointerId;
|
||||
dragValue = value.value;
|
||||
control.value.setPointerCapture(event.pointerId);
|
||||
isDragging.value = true;
|
||||
animateFill.value = true;
|
||||
control.value.focus();
|
||||
handleDrag(event);
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function handleDragEnd(event: PointerEvent) {
|
||||
if (!isDragging.value || event.pointerId !== dragPointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === 'pointerup') {
|
||||
handleDrag(event);
|
||||
}
|
||||
isDragging.value = false;
|
||||
dragPointerId = undefined;
|
||||
if (control.value?.hasPointerCapture(event.pointerId)) {
|
||||
control.value.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
if (!props.disabled && event.type === 'pointerup') {
|
||||
emit('valueCommit', [dragValue]);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="[$style.wrapper, sizeClass]">
|
||||
<div v-if="hasLabel() || !hideValue" :class="$style.header">
|
||||
<N8nText
|
||||
v-if="hasLabel()"
|
||||
:id="labelId"
|
||||
:step="fontSize"
|
||||
bold
|
||||
color="text-base"
|
||||
:class="hideLabel ? $style.hiddenLabel : $style.label"
|
||||
>
|
||||
<slot name="label">{{ label }}</slot>
|
||||
</N8nText>
|
||||
<N8nText
|
||||
v-if="!hideValue"
|
||||
:step="fontSize"
|
||||
bold
|
||||
color="text-dark"
|
||||
:class="$style.value"
|
||||
aria-hidden="true"
|
||||
>{{ formattedValue }}</N8nText
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
:class="[
|
||||
$style.slider,
|
||||
sizeClass,
|
||||
{ [$style.disabled]: disabled, [$style.dragging]: isDragging },
|
||||
]"
|
||||
@pointerdown="handleDragStart"
|
||||
@pointermove="handleDrag"
|
||||
@pointerup="handleDragEnd"
|
||||
@pointercancel="handleDragEnd"
|
||||
@lostpointercapture="handleDragEnd"
|
||||
>
|
||||
<div
|
||||
ref="control"
|
||||
:class="$style.control"
|
||||
role="slider"
|
||||
:tabindex="disabled ? -1 : 0"
|
||||
:aria-disabled="disabled"
|
||||
:aria-valuenow="value"
|
||||
:aria-valuetext="String(formattedValue)"
|
||||
:aria-label="ariaLabel"
|
||||
:aria-labelledby="getAccessibleLabelledBy()"
|
||||
:aria-valuemin="minValue"
|
||||
:aria-valuemax="maxValue"
|
||||
@keydown="handleKeyDown"
|
||||
>
|
||||
<div
|
||||
:class="[$style.fill, { [$style.animateFill]: animateFill }]"
|
||||
:style="fillStyle"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div :class="$style.hashmarks" aria-hidden="true">
|
||||
<div v-for="marker in markers" :key="marker" :class="$style.hashmark" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
@use '../../css/mixins/motion';
|
||||
@use '../../css/mixins/focus';
|
||||
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--4xs);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--input--padding);
|
||||
height: var(--n8n-slider--height, var(--height--lg));
|
||||
padding-inline: var(--n8n-slider--padding-inline, var(--spacing--xs));
|
||||
border-radius: var(--n8n-slider--radius, var(--radius--3xs));
|
||||
border: 1px solid var(--n8n-slider--background, var(--border-color));
|
||||
background-color: var(--n8n-slider--background, var(--background--subtle));
|
||||
overflow: hidden;
|
||||
|
||||
&:focus-within {
|
||||
@include focus.focus-ring-with-border;
|
||||
}
|
||||
|
||||
&:not(.disabled):hover .fill::after {
|
||||
outline-color: var(--border-color);
|
||||
}
|
||||
|
||||
&:not(.disabled):hover .hashmarks {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.dragging:not(.disabled) {
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
cursor: not-allowed;
|
||||
background-color: var(--background--disabled);
|
||||
border-color: var(--background--disabled);
|
||||
|
||||
.fill {
|
||||
background-color: var(--background--brand--disabled);
|
||||
|
||||
&::after {
|
||||
background-color: var(--background--subtle);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.mini {
|
||||
height: var(--n8n-slider--height, var(--height--xs));
|
||||
padding-inline: var(--n8n-slider--padding-inline, var(--spacing--3xs));
|
||||
}
|
||||
|
||||
&.small {
|
||||
height: var(--n8n-slider--height, var(--height--sm));
|
||||
padding-inline: var(--n8n-slider--padding-inline, var(--spacing--2xs));
|
||||
}
|
||||
|
||||
&.medium {
|
||||
height: var(--n8n-slider--height, var(--height--md));
|
||||
}
|
||||
|
||||
&.large {
|
||||
border-radius: var(--n8n-slider--radius, var(--radius--2xs));
|
||||
}
|
||||
|
||||
&.xlarge {
|
||||
height: var(--n8n-slider--height, var(--height--xl));
|
||||
padding-inline: var(--n8n-slider--padding-inline, var(--spacing--sm));
|
||||
border-radius: var(--n8n-slider--radius, var(--radius--2xs));
|
||||
}
|
||||
}
|
||||
.control {
|
||||
container-type: size;
|
||||
touch-action: none;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
border-radius: inherit;
|
||||
overflow: hidden;
|
||||
}
|
||||
.hashmarks {
|
||||
position: absolute;
|
||||
inset: 0 5%;
|
||||
z-index: 1;
|
||||
opacity: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(9, minmax(0, 1fr));
|
||||
place-items: center;
|
||||
gap: 0;
|
||||
transition: opacity var(--duration--snappy) var(--easing--ease-out);
|
||||
}
|
||||
.hashmark {
|
||||
width: var(--spacing--4xs);
|
||||
height: var(--spacing--4xs);
|
||||
border-radius: 50%;
|
||||
background-color: var(--border-color);
|
||||
}
|
||||
|
||||
.fill {
|
||||
isolation: isolate;
|
||||
position: relative;
|
||||
/** Keep the handle and its outline inside the control at the minimum value. */
|
||||
min-width: 100cqh;
|
||||
height: 100%;
|
||||
background-color: var(
|
||||
--n8n-slider--fill,
|
||||
color-mix(in oklch, var(--background--subtle), black 8%)
|
||||
);
|
||||
will-change: width;
|
||||
border-top-right-radius: var(--n8n-slider--radius, var(--radius--3xs));
|
||||
border-bottom-right-radius: var(--n8n-slider--radius, var(--radius--3xs));
|
||||
z-index: 2;
|
||||
|
||||
&::before {
|
||||
content: '⋮';
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
inset-inline-end: 1px;
|
||||
inset-block: 1px;
|
||||
display: grid;
|
||||
aspect-ratio: 1;
|
||||
place-items: center;
|
||||
font-size: var(--font-size--md);
|
||||
font-weight: var(--font-weight--bold);
|
||||
text-align: center;
|
||||
color: var(--text-color--disabled);
|
||||
box-shadow: var(--shadow--xs);
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset-inline-end: 1px;
|
||||
inset-block: 1px;
|
||||
border-radius: var(--n8n-slider--radius, var(--radius--3xs));
|
||||
opacity: 1;
|
||||
transition: all var(--duration--snappy) var(--easing--ease-out);
|
||||
aspect-ratio: 1;
|
||||
background-color: light-dark(var(--background--surface), var(--color--neutral-700));
|
||||
box-shadow: var(--shadow--xs);
|
||||
outline: 1px solid transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.animateFill {
|
||||
@include motion.width-transition;
|
||||
}
|
||||
|
||||
.hiddenLabel {
|
||||
position: absolute;
|
||||
width: var(--spacing--5xs);
|
||||
height: var(--spacing--5xs);
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.value {
|
||||
margin-inline-start: auto;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,196 @@
|
|||
# Component specification
|
||||
|
||||
An input that lets the user select one numeric value within given limits.
|
||||
|
||||
- **Component name:** N8nSlider
|
||||
- **Source of truth:** `Slider.vue`
|
||||
|
||||
## Scope
|
||||
|
||||
- Supports controlled and uncontrolled values.
|
||||
- Uses a filled track with a position indicator, a label, and a value.
|
||||
- Supports pointer input, including mouse and touch, and keyboard input.
|
||||
- Supports one value only. It does not support ranges.
|
||||
- Pointer values increase from left to right. The component does not reverse this behavior for right-to-left layouts.
|
||||
|
||||
## Public API
|
||||
|
||||
### Props
|
||||
|
||||
| Prop | Type | Default | Behavior |
|
||||
| ----------------- | --------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `size` | `InputSize` | `'large'` | Supports `mini`, `small`, `medium`, `large`, and `xlarge`. Sets height, padding, radius, and text size. |
|
||||
| `modelValue` | `SliderValue` | `undefined` | Supplies the value in controlled mode. Supports `v-model`. |
|
||||
| `defaultValue` | `SliderValue` | `undefined` | Supplies the initial local value. If omitted, the local value starts at `minValue`. Later changes do not reset it. |
|
||||
| `maxValue` | `number` | `100` | Upper limit for user input. |
|
||||
| `minValue` | `number` | `0` | Lower limit for user input. |
|
||||
| `step` | `number` | `1` | Step interval. Pointer steps start at `minValue`. See the input rules below. |
|
||||
| `minDistance` | `number` | `0` | Accepted but not used. There is no range mode. |
|
||||
| `label` | `string` | `undefined` | Supplies the visible label and default accessible name. Supply a label, a label slot, or an ARIA name. |
|
||||
| `ariaLabel` | `string` | `undefined` | Names the slider without a visible label. |
|
||||
| `ariaLabelledby` | `string` | `undefined` | References an external label. Takes priority over other accessible names. |
|
||||
| `hideLabel` | `boolean` | `false` | Visually hides the label but keeps its accessible name. |
|
||||
| `showStepMarkers` | `boolean` | `true` | Adds nine markers at 10% intervals when `maxValue > minValue`. Markers appear on hover, not keyboard focus. They do not follow `step`. |
|
||||
| `disabled` | `boolean` | `false` | Blocks user input and removes the control from the tab order. |
|
||||
| `formatValue` | `(value: number) => string` | `undefined` | Formats the visible value and accessible value text. Uses the numeric value when omitted. |
|
||||
| `hideValue` | `boolean` | `false` | Removes the visible value. Keeps accessible value text. |
|
||||
|
||||
### Types
|
||||
|
||||
`Slider.types.ts` defines the public prop and event interfaces.
|
||||
|
||||
```typescript
|
||||
export type SliderValue = [number];
|
||||
```
|
||||
|
||||
Only the first array item supplies the value. All emitted values are new one-item arrays.
|
||||
|
||||
### Events
|
||||
|
||||
| Event | Payload | When it emits |
|
||||
| ------------------- | ------------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| `update:modelValue` | `SliderValue` | When pointer or keyboard input changes the value. |
|
||||
| `valueCommit` | `SliderValue` | On a changed keyboard value, or when an active pointer interaction ends with `pointerup` while enabled. |
|
||||
|
||||
A pointer release emits `valueCommit` even if the value did not change. Pointer cancellation and lost pointer capture end the interaction without a commit. External prop updates do not emit either event.
|
||||
|
||||
### Slots
|
||||
|
||||
The optional `label` slot replaces the visible label text. The component gives the rendered label element an ID for `aria-labelledby`. The `label` prop supplies fallback text.
|
||||
|
||||
## Behavior
|
||||
|
||||
### Value state
|
||||
|
||||
- `modelValue[0]` takes priority over the local value.
|
||||
- The local value starts at `defaultValue[0]`, or at the initial `minValue`.
|
||||
- User input updates the local value and emits a new array. It does not mutate consumer arrays.
|
||||
- In controlled mode, the consumer must update `modelValue` to show the new value.
|
||||
- Initial values and external values are not clamped or aligned to `step`.
|
||||
- The fill width is clamped to 0–100%. It is zero when `maxValue <= minValue`.
|
||||
|
||||
### Pointer input
|
||||
|
||||
- A primary-button press on the track starts the interaction and focuses the control.
|
||||
- The control captures the pointer. Other pointers do not change the active interaction.
|
||||
- The pointer position maps to a value across the full control width.
|
||||
- A positive `step` rounds the value to the nearest step from `minValue`.
|
||||
- A zero or negative `step` allows continuous pointer values.
|
||||
- The result is limited to `minValue` and `maxValue`.
|
||||
- The initial press enables a fill-width transition. Pointer movement disables that transition.
|
||||
- A pointer release applies its final position before it emits `valueCommit`.
|
||||
- Pointer movement does not change the value if the control width is zero or `maxValue <= minValue`.
|
||||
|
||||
### Keyboard input
|
||||
|
||||
Keyboard input is ignored when disabled or during a pointer interaction.
|
||||
|
||||
| Key | Action |
|
||||
| ------------------------ | ------------------------------------------- |
|
||||
| Arrow Right or Arrow Up | Increase by one step. |
|
||||
| Arrow Left or Arrow Down | Decrease by one step. |
|
||||
| Shift + Arrow key | Change by ten steps in the arrow direction. |
|
||||
| Page Up | Increase by ten steps. |
|
||||
| Page Down | Decrease by ten steps. |
|
||||
| Home | Set the value to `minValue`. |
|
||||
| End | Set the value to `maxValue`. |
|
||||
|
||||
Keyboard input uses a step of `1` when `step <= 0`. Arrow and Page keys change the current value directly; they do not align it to a step grid. Each result is limited to the configured bounds. A changed value emits `update:modelValue`, then `valueCommit`. An unchanged value emits neither event.
|
||||
|
||||
### Appearance
|
||||
|
||||
- The component uses design tokens for colors and sizes.
|
||||
- `mini` uses text size `2xs`. `xlarge` uses `sm`. Other sizes use `xs`.
|
||||
- The label and value appear in a row above the track.
|
||||
- The visible value uses tabular numerals.
|
||||
- The position indicator is always visible. Hover changes its outline color and shows enabled markers when the slider is enabled.
|
||||
- Dragging changes the cursor to indicate an active interaction.
|
||||
- Keyboard focus shows a focus ring around the component.
|
||||
- Disabled sliders use disabled track and fill colors and a muted position indicator without a shadow. They show a not-allowed cursor and do not apply hover treatments.
|
||||
|
||||
## Accessibility
|
||||
|
||||
Supply a non-empty `label`, a `label` slot, `ariaLabel`, or `ariaLabelledby`. The consumer must supply an accessible name. The component does not validate this at runtime. Slot content must supply meaningful label text. An external label reference must point to an existing element with label text.
|
||||
|
||||
- The track is the single keyboard focus target and has `role="slider"`.
|
||||
- `aria-labelledby` refers to the label outside the slider role.
|
||||
- `aria-valuenow` contains the numeric value.
|
||||
- `aria-valuemin` and `aria-valuemax` contain the configured limits.
|
||||
- `aria-valuetext` contains the formatted value converted to a string.
|
||||
- `aria-disabled` follows `disabled`. Disabled controls have `tabindex="-1"`; enabled controls have `tabindex="0"`.
|
||||
- The fill, markers, and visible value are hidden from assistive technology.
|
||||
- `hideValue` does not remove `aria-valuetext`.
|
||||
- The `hideLabel` prop visually hides the label without removing its accessible name.
|
||||
- ARIA naming props apply to the slider control, not the outer wrapper.
|
||||
- Accessible name priority is `ariaLabelledby`, then `ariaLabel`, then the internal label.
|
||||
- The control uses `touch-action: none` for pointer input.
|
||||
- Consumers must translate labels and formatted value text.
|
||||
|
||||
## Template usage examples
|
||||
|
||||
### Controlled value
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<N8nSlider v-model="volume" :label="volumeLabel" />
|
||||
</template>
|
||||
```
|
||||
|
||||
`volume` has type `[number]`. `volumeLabel` contains translated text.
|
||||
|
||||
### Uncontrolled value
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<N8nSlider :default-value="[50]" :label="volumeLabel" />
|
||||
</template>
|
||||
```
|
||||
|
||||
### Formatted choices
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<N8nSlider
|
||||
v-model="size"
|
||||
:label="sizeLabel"
|
||||
:min-value="0"
|
||||
:max-value="3"
|
||||
:format-value="formatSize"
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
|
||||
`formatSize` returns a translated size name for each numeric value. For a short list of named choices, consider a select or segmented control to make the available choices easier to find.
|
||||
|
||||
### Hidden visible value
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<N8nSlider v-model="volume" :label="volumeLabel" hide-value />
|
||||
</template>
|
||||
```
|
||||
|
||||
### Disabled slider
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<N8nSlider :default-value="[50]" :label="volumeLabel" disabled />
|
||||
</template>
|
||||
```
|
||||
|
||||
### Decimal steps and markers
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<N8nSlider
|
||||
v-model="opacity"
|
||||
:label="opacityLabel"
|
||||
:min-value="0"
|
||||
:max-value="1"
|
||||
:step="0.1"
|
||||
show-step-markers
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
|
||||
Markers stay at 10% intervals even when `step` changes.
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import N8nSlider from './Slider.vue';
|
||||
|
||||
export default N8nSlider;
|
||||
export { N8nSlider };
|
||||
export type * from './Slider.types';
|
||||
|
|
@ -170,6 +170,8 @@ export { default as N8nSticky } from './N8nSticky';
|
|||
export { default as N8nResizeableSticky } from './N8nResizeableSticky';
|
||||
export { default as N8nSuggestedActions } from './N8nSuggestedActions';
|
||||
export { default as N8nSwitch } from './N8nSwitch';
|
||||
export { default as N8nSlider } from './N8nSlider';
|
||||
export type * from './N8nSlider/Slider.types';
|
||||
export { default as N8nTabs } from './N8nTabs';
|
||||
export { default as N8nTag } from './N8nTag';
|
||||
export { default as N8nTags } from './N8nTags';
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { defineComponent } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { N8nInputNumber, N8nSwitch2 } from '@n8n/design-system';
|
||||
import { ElSlider } from 'element-plus';
|
||||
import { N8nInputNumber, N8nSlider, N8nSwitch2 } from '@n8n/design-system';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import AgentBudgetMonthlyModal from '../components/AgentBudgetMonthlyModal.vue';
|
||||
|
|
@ -55,11 +54,20 @@ describe('AgentBudgetMonthlyModal', () => {
|
|||
|
||||
it('uses a slider that moves in steps of 10', () => {
|
||||
const wrapper = mountModal();
|
||||
const slider = wrapper.getComponent(ElSlider);
|
||||
expect(slider.props('min')).toBe(10);
|
||||
expect(slider.props('max')).toBe(100);
|
||||
const slider = wrapper.getComponent(N8nSlider);
|
||||
expect(slider.props('minValue')).toBe(10);
|
||||
expect(slider.props('maxValue')).toBe(100);
|
||||
expect(slider.props('step')).toBe(10);
|
||||
expect(slider.props('showStops')).toBe(true);
|
||||
expect(slider.props('showStepMarkers')).toBe(true);
|
||||
});
|
||||
|
||||
it('saves the numeric percent from the shared slider', async () => {
|
||||
const wrapper = mountModal();
|
||||
wrapper.getComponent(N8nSlider).vm.$emit('update:modelValue', [60]);
|
||||
await wrapper.get('[data-testid="agent-budget-monthly-save"]').trigger('click');
|
||||
|
||||
const saved = wrapper.emitted('save')?.[0]?.[0] as Partial<AgentJsonConfig>;
|
||||
expect(saved.config?.guardrails?.budget?.alertThresholdPercent).toBe(60);
|
||||
});
|
||||
|
||||
it('snaps a saved percent to the nearest step of 10', () => {
|
||||
|
|
@ -69,7 +77,7 @@ describe('AgentBudgetMonthlyModal', () => {
|
|||
props: { open: true, config: saved },
|
||||
global: { stubs: { AgentModal: AgentModalStub } },
|
||||
});
|
||||
expect(wrapper.getComponent(ElSlider).props('modelValue')).toBe(70);
|
||||
expect(wrapper.getComponent(N8nSlider).props('modelValue')).toEqual([70]);
|
||||
});
|
||||
|
||||
it('drops a monthly budget of 0 and its alert', async () => {
|
||||
|
|
@ -122,7 +130,7 @@ describe('AgentBudgetMonthlyModal', () => {
|
|||
|
||||
expect(wrapper.getComponent(N8nInputNumber).props('disabled')).toBe(true);
|
||||
expect(wrapper.getComponent(N8nSwitch2).props('disabled')).toBe(true);
|
||||
expect(wrapper.getComponent(ElSlider).props('disabled')).toBe(true);
|
||||
expect(wrapper.getComponent(N8nSlider).props('disabled')).toBe(true);
|
||||
expect(
|
||||
wrapper.get('[data-testid="agent-budget-monthly-save"]').attributes('disabled'),
|
||||
).toBeDefined();
|
||||
|
|
|
|||
|
|
@ -33,6 +33,18 @@ describe('AgentEvalExamplesSlider', () => {
|
|||
expect(queryByTestId('agent-eval-examples-slider-loading')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('names the slider with the visible count and keeps preview controls available', async () => {
|
||||
const { getByRole, getAllByTestId } = renderComponent({ props: { disabled: true } });
|
||||
const slider = getByRole('slider', { name: '2 of 2 checks' });
|
||||
|
||||
expect(slider).toHaveAttribute('aria-valuemin', '1');
|
||||
expect(slider).toHaveAttribute('aria-valuemax', '2');
|
||||
await fireEvent.keyDown(slider, { key: 'Home' });
|
||||
|
||||
expect(getByRole('slider', { name: '1 of 2 checks' })).toHaveAttribute('aria-valuenow', '1');
|
||||
expect(getAllByTestId('instance-ai-test-agent-examples-example')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('shows a spinner saying cases are being generated, instead of the slider/list/input', () => {
|
||||
const { getByTestId, getByText, queryByTestId } = renderComponent({
|
||||
props: { loading: true, examples: [] },
|
||||
|
|
|
|||
|
|
@ -6,8 +6,7 @@
|
|||
*/
|
||||
import { computed, ref, useId, watch } from 'vue';
|
||||
import type { AgentEvalDraftCase } from '@n8n/api-types';
|
||||
import { ElSlider } from 'element-plus';
|
||||
import { N8nIcon, N8nSpinner, N8nText } from '@n8n/design-system';
|
||||
import { N8nIcon, N8nSlider, N8nSpinner, N8nText, type SliderValue } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import AgentAvatar from '@/features/agents/components/AgentAvatar.vue';
|
||||
import AgentEvalTryRow from '@/features/agents/components/AgentEvalTryRow.vue';
|
||||
|
|
@ -125,6 +124,11 @@ function cancelAddOwn() {
|
|||
|
||||
const ownInputRef = ref<HTMLInputElement | null>(null);
|
||||
const ownInputId = useId();
|
||||
const sliderCountId = useId();
|
||||
|
||||
function updateSliderValue([value]: SliderValue) {
|
||||
sliderValue.value = value;
|
||||
}
|
||||
|
||||
function focusOwnInput() {
|
||||
ownInputRef.value?.focus();
|
||||
|
|
@ -146,7 +150,7 @@ defineExpose({ sliderValue, focusOwnInput });
|
|||
</div>
|
||||
<div v-else :class="$style.root">
|
||||
<div :class="$style.sliderRow" data-test-id="instance-ai-test-agent-examples-slider">
|
||||
<N8nText :class="$style.sliderCount" bold size="medium">
|
||||
<N8nText :id="sliderCountId" :class="$style.sliderCount" bold size="medium">
|
||||
{{
|
||||
i18n.baseText('instanceAi.testAgentPreview.examplesCount', {
|
||||
adjustToNumber: sliderValue,
|
||||
|
|
@ -154,14 +158,17 @@ defineExpose({ sliderValue, focusOwnInput });
|
|||
})
|
||||
}}
|
||||
</N8nText>
|
||||
<ElSlider
|
||||
v-model="sliderValue"
|
||||
:min="1"
|
||||
:max="maxSliderValue"
|
||||
<N8nSlider
|
||||
:model-value="[sliderValue]"
|
||||
:min-value="1"
|
||||
:max-value="maxSliderValue"
|
||||
:step="1"
|
||||
:show-tooltip="false"
|
||||
:show-step-markers="false"
|
||||
hide-value
|
||||
:aria-labelledby="sliderCountId"
|
||||
:class="$style.slider"
|
||||
size="small"
|
||||
@update:model-value="updateSliderValue"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -265,32 +272,10 @@ defineExpose({ sliderValue, focusOwnInput });
|
|||
}
|
||||
|
||||
.slider {
|
||||
--n8n-slider--fill: var(--background--brand);
|
||||
--n8n-slider--background: var(--run-data--color--background);
|
||||
flex: 1;
|
||||
cursor: ew-resize;
|
||||
|
||||
:global(.el-slider__runway) {
|
||||
background-color: var(--run-data--color--background);
|
||||
border: none;
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
:global(.el-slider__bar) {
|
||||
background-color: var(--color--orange-500);
|
||||
border-radius: var(--radius--full);
|
||||
}
|
||||
|
||||
:global(.el-slider__button-wrapper) {
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
:global(.el-slider__button) {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 1px solid var(--color--orange-500);
|
||||
background-color: var(--background--surface);
|
||||
box-shadow: none;
|
||||
cursor: ew-resize;
|
||||
}
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sliderCount {
|
||||
|
|
|
|||
|
|
@ -1,14 +1,9 @@
|
|||
<script setup lang="ts">
|
||||
import { ElSlider } from 'element-plus';
|
||||
|
||||
/**
|
||||
* Local slider for the monthly budget alert. The design system has no slider.
|
||||
* Keep this next to the budget modal. Do not promote it.
|
||||
*/
|
||||
import { N8nSlider, type SliderValue } from '@n8n/design-system';
|
||||
|
||||
defineProps<{
|
||||
modelValue: number;
|
||||
/** Accessible name for the slider; forwarded to ElSlider's `label` prop, which sets `aria-label`. */
|
||||
/** Names the slider for assistive technology. */
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
|
|
@ -17,27 +12,21 @@ const emit = defineEmits<{
|
|||
'update:modelValue': [value: number];
|
||||
}>();
|
||||
|
||||
const brandColor = {
|
||||
'--el-slider-main-bg-color': 'var(--background--brand)',
|
||||
} as const;
|
||||
|
||||
function onUpdate(value: number | number[]) {
|
||||
const next = Array.isArray(value) ? value[0] : value;
|
||||
if (next === undefined) return;
|
||||
emit('update:modelValue', next);
|
||||
function onUpdate([value]: SliderValue) {
|
||||
emit('update:modelValue', value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElSlider
|
||||
:model-value="modelValue"
|
||||
:min="10"
|
||||
<N8nSlider
|
||||
:model-value="[modelValue]"
|
||||
:min-value="10"
|
||||
:step="10"
|
||||
:show-stops="true"
|
||||
:show-tooltip="false"
|
||||
show-step-markers
|
||||
hide-value
|
||||
size="small"
|
||||
:disabled="disabled"
|
||||
:label="label"
|
||||
:style="brandColor"
|
||||
:aria-label="label"
|
||||
:class="$style.slider"
|
||||
data-testid="agent-budget-alert-slider"
|
||||
@update:model-value="onUpdate"
|
||||
|
|
@ -45,25 +34,9 @@ function onUpdate(value: number | number[]) {
|
|||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
/* A bar at 100% grows this flex item past the dialog and the track wraps. */
|
||||
.slider {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
flex-wrap: nowrap;
|
||||
/* The thumb's hit area (wrapper, not button) is centred on the value, so reserve half of it. */
|
||||
padding-inline: calc(var(--el-slider-button-wrapper-size) / 2);
|
||||
|
||||
:global(.el-slider__runway) {
|
||||
flex: 1 1 0%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
:global(.el-slider__bar) {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,19 +1,15 @@
|
|||
<script setup lang="ts">
|
||||
import { ElSlider } from 'element-plus';
|
||||
|
||||
// Feature-local slider for the parallel-evaluation concurrency control.
|
||||
// Lives here (not in `@n8n/design-system`) because it bakes in a single
|
||||
// hard-coded brand-token theme — that bypasses the variant story DS
|
||||
// components are expected to support and would need design sign-off
|
||||
// before being promoted. Promote when both conditions hold.
|
||||
import { N8nSlider, type SliderValue } from '@n8n/design-system';
|
||||
|
||||
interface ConcurrencySliderProps {
|
||||
modelValue?: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
disabled?: boolean;
|
||||
/** @deprecated Accepted for compatibility. Fixed percentage markers do not represent concurrency steps. */
|
||||
showStops?: boolean;
|
||||
showTooltip?: boolean;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<ConcurrencySliderProps>(), {
|
||||
|
|
@ -22,77 +18,43 @@ withDefaults(defineProps<ConcurrencySliderProps>(), {
|
|||
step: 1,
|
||||
disabled: false,
|
||||
showStops: false,
|
||||
showTooltip: true,
|
||||
});
|
||||
|
||||
// Element-plus's `.el-slider` rule sets these CSS vars at class-level
|
||||
// specificity. Inline `:style` wins by specificity, guaranteeing the
|
||||
// override regardless of stylesheet load order.
|
||||
//
|
||||
// Discord-like pill aesthetic: tall rounded runway, white thumb sitting
|
||||
// inside the track without a visible coloured border ring, and subtle
|
||||
// stops as dots rather than dividers.
|
||||
//
|
||||
// `--background--brand` is the semantic alias for the n8n orange. The
|
||||
// runway/disabled tokens fall back to legacy `--color--foreground*`
|
||||
// because the design system has no semantic alias for the
|
||||
// "neutral surface behind an interactive control" role yet.
|
||||
const brandTokens = {
|
||||
'--el-slider-main-bg-color': 'var(--background--brand)',
|
||||
'--el-slider-runway-bg-color': 'var(--color--foreground)',
|
||||
'--el-slider-stop-bg-color': 'rgba(0, 0, 0, 0.28)',
|
||||
'--el-slider-disabled-color': 'var(--color--foreground--shade-1)',
|
||||
'--el-color-white': '#fff',
|
||||
'--el-slider-button-size': '16px',
|
||||
'--el-slider-button-wrapper-size': '28px',
|
||||
'--el-slider-height': '20px',
|
||||
'--el-slider-border-radius': '20px',
|
||||
// Vertically centre the thumb wrapper inside the runway. Element-plus's
|
||||
// default `-15px` was tuned for a 6px runway and 36px wrapper. With our
|
||||
// 20px runway and 28px wrapper, the offset is `(runway - wrapper) / 2`
|
||||
// = `(20 - 28) / 2` = -4px.
|
||||
'--el-slider-button-wrapper-offset': '-4px',
|
||||
} as const;
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: number];
|
||||
change: [value: number];
|
||||
}>();
|
||||
|
||||
function onUpdate([value]: SliderValue) {
|
||||
emit('update:modelValue', value);
|
||||
}
|
||||
|
||||
function onCommit([value]: SliderValue) {
|
||||
emit('change', value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElSlider
|
||||
:min="min"
|
||||
:max="max"
|
||||
<N8nSlider
|
||||
:model-value="modelValue === undefined ? undefined : [modelValue]"
|
||||
:min-value="min"
|
||||
:max-value="max"
|
||||
:step="step"
|
||||
:disabled="disabled"
|
||||
:show-stops="showStops"
|
||||
:show-tooltip="showTooltip"
|
||||
:style="brandTokens"
|
||||
:show-step-markers="false"
|
||||
:aria-label="label"
|
||||
hide-value
|
||||
size="small"
|
||||
:class="$style.slider"
|
||||
v-bind="$attrs"
|
||||
@update:model-value="onUpdate"
|
||||
@value-commit="onCommit"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
// Element-plus's slider thumb has `border: 2px solid var(--el-slider-main-bg-color)`,
|
||||
// which puts a primary-coloured ring around the thumb. The Discord-like look
|
||||
// the design wants is a borderless white thumb sitting inside the track. Force
|
||||
// the border to match the thumb fill (effectively removing the ring) and add
|
||||
// a subtle shadow for depth. `:global` is needed because the .el-slider__button
|
||||
// class is rendered by ElSlider (outside this component's scoped CSS).
|
||||
.slider {
|
||||
:global(.el-slider__button) {
|
||||
border-color: var(--el-color-white);
|
||||
box-shadow:
|
||||
0 2px 4px rgba(0, 0, 0, 0.12),
|
||||
0 0 0 1px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
// Element-plus's default stops are 4px-wide vertical rectangles spanning
|
||||
// the full runway height, which look like dividers on a tall pill track.
|
||||
// Rounded dots match the Discord-style visual.
|
||||
:global(.el-slider__stop) {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
--n8n-slider--fill: var(--background--brand);
|
||||
--n8n-slider--radius: var(--radius--full);
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -234,7 +234,7 @@ watch(runningTestRun, (run) => {
|
|||
:max="parallelEvalStore.maxConcurrency"
|
||||
:step="1"
|
||||
show-stops
|
||||
:show-tooltip="false"
|
||||
:label="locale.baseText('evaluation.runInParallel.popover.title')"
|
||||
:class="$style.concurrencySlider"
|
||||
data-test-id="run-in-parallel-concurrency"
|
||||
/>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user