refactor(editor): Migrate Button v2 to N8nButton (no-changelog) (#24947)

This commit is contained in:
Rob Hough
2026-02-13 10:48:03 +00:00
committed by GitHub
parent 1ce270d129
commit 7ab7911c2f
232 changed files with 2202 additions and 2750 deletions
@@ -0,0 +1,418 @@
#!/usr/bin/env node
/**
* Button V2 Migration Codemod
*
* This script migrates N8nButton components from the legacy V1 API to the V2 API.
*
* Transformations:
* - type="primary" → variant="solid"
* - type="secondary" → variant="subtle"
* - type="tertiary" → variant="subtle"
* - type="danger" → variant="destructive"
* - type="success" → variant="solid" class="n8n-button--success"
* - type="warning" → variant="solid" class="n8n-button--warning"
* - type="highlight" → variant="ghost" class="n8n-button--highlight"
* - type="highlightFill" → variant="subtle" class="n8n-button--highlightFill"
* - outline prop → variant="outline"
* - text prop → variant="ghost"
* - size="xmini"|"mini" → size="xsmall"
* - square → iconOnly
* - nativeType → type attribute
* - block → style="width: 100%"
* - element="a" → (removed, href determines element)
* Usage:
* node migrate-button-v2.mjs [--dry-run]
*/
import { readFileSync, writeFileSync, readdirSync, statSync } from 'fs';
import { join, relative } from 'path';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Configuration
const FRONTEND_ROOT = join(__dirname, '../../..');
const DRY_RUN = process.argv.includes('--dry-run');
// Mapping from legacy type to new variant
const TYPE_TO_VARIANT = {
primary: 'solid',
secondary: 'subtle',
tertiary: 'subtle',
danger: 'destructive',
};
// Legacy types that need override classes
const LEGACY_TYPES_WITH_CLASSES = {
success: { variant: 'solid', className: 'n8n-button--success' },
warning: { variant: 'solid', className: 'n8n-button--warning' },
highlight: { variant: 'ghost', className: 'n8n-button--highlight' },
highlightFill: { variant: 'subtle', className: 'n8n-button--highlightFill' },
};
// Size normalization
const SIZE_MAP = {
xmini: 'xsmall',
mini: 'xsmall',
};
// Stats
const stats = {
filesScanned: 0,
filesModified: 0,
transformations: {
typeToVariant: 0,
legacyTypeWithClass: 0,
outlineToVariant: 0,
textToVariant: 0,
sizeNormalized: 0,
squareToIconOnly: 0,
nativeTypeToType: 0,
blockToStyle: 0,
elementRemoved: 0,
},
};
/**
* Find all .vue files recursively
*/
function findVueFiles(dir, files = []) {
const entries = readdirSync(dir);
for (const entry of entries) {
const fullPath = join(dir, entry);
// Skip node_modules and hidden directories
if (entry === 'node_modules' || entry.startsWith('.')) continue;
const stat = statSync(fullPath);
if (stat.isDirectory()) {
findVueFiles(fullPath, files);
} else if (entry.endsWith('.vue')) {
files.push(fullPath);
}
}
return files;
}
/**
* Transform a single N8nButton tag
*/
function transformButtonTag(fullMatch, tagContent, selfClosing, content, closingTag) {
let modified = false;
const changes = [];
// Track what we need to add
let newVariant = null;
let addClass = null;
let addStyle = null;
// Parse current attributes (handles both static and v-bind shorthand :prop)
const hasType = /\btype=["']([^"']+)["']/.exec(tagContent);
const hasVariant = /\bvariant=["']/.test(tagContent);
const hasOutline = /\b:?outline(?:=["']true["'])?(?=\s|\/?>|\s)/.test(tagContent);
const hasText = /\b:?text(?:=["']true["'])?(?=\s|\/?>|\s)/.test(tagContent);
const hasSize = /\b:?size=["']([^"']+)["']/.exec(tagContent);
const hasSquare = /\b:?square(?:=["']true["'])?(?=\s|\/?>|\s)/.test(tagContent);
const hasNativeType = /\b:?nativeType=["']([^"']+)["']/.exec(tagContent);
const hasBlock = /\b:?block(?:=["']true["'])?(?=\s|\/?>|\s)/.test(tagContent);
const hasElement = /\b:?element=["']([^"']+)["']/.exec(tagContent);
const hasClass = /\bclass=["']([^"']+)["']/.exec(tagContent);
const hasStyle = /\bstyle=["']([^"']+)["']/.exec(tagContent);
// Skip if already using variant (already migrated)
if (hasVariant) {
return fullMatch;
}
// 1. Handle outline prop → variant="outline"
if (hasOutline && !hasText) {
newVariant = 'outline';
tagContent = tagContent.replace(/\s*\b:?outline(?:=["']true["'])?(?=\s|\/?>)/, '');
// Also remove type if present since outline takes precedence
if (hasType) {
tagContent = tagContent.replace(/\s*\btype=["'][^"']+["']/, '');
}
changes.push('outline → variant="outline"');
stats.transformations.outlineToVariant++;
modified = true;
}
// 2. Handle text prop → variant="ghost"
else if (hasText) {
newVariant = 'ghost';
tagContent = tagContent.replace(/\s*\b:?text(?:=["']true["'])?(?=\s|\/?>)/, '');
// Also remove type and outline if present
if (hasType) {
tagContent = tagContent.replace(/\s*\btype=["'][^"']+["']/, '');
}
if (hasOutline) {
tagContent = tagContent.replace(/\s*\b:?outline(?:=["']true["'])?(?=\s|\/?>)/, '');
}
changes.push('text → variant="ghost"');
stats.transformations.textToVariant++;
modified = true;
}
// 3. Handle type prop
else if (hasType) {
const typeValue = hasType[1];
if (TYPE_TO_VARIANT[typeValue]) {
// Direct mapping
newVariant = TYPE_TO_VARIANT[typeValue];
tagContent = tagContent.replace(/\s*\btype=["'][^"']+["']/, '');
changes.push(`type="${typeValue}" → variant="${newVariant}"`);
stats.transformations.typeToVariant++;
modified = true;
} else if (LEGACY_TYPES_WITH_CLASSES[typeValue]) {
// Legacy type with class override
const mapping = LEGACY_TYPES_WITH_CLASSES[typeValue];
newVariant = mapping.variant;
addClass = mapping.className;
tagContent = tagContent.replace(/\s*\btype=["'][^"']+["']/, '');
changes.push(`type="${typeValue}" → variant="${newVariant}" + class="${addClass}"`);
stats.transformations.legacyTypeWithClass++;
modified = true;
}
}
// 4. Handle size normalization
if (hasSize && SIZE_MAP[hasSize[1]]) {
const oldSize = hasSize[1];
const newSize = SIZE_MAP[oldSize];
tagContent = tagContent.replace(/\b:?size=["'][^"']+["']/, `size="${newSize}"`);
changes.push(`size="${oldSize}" → size="${newSize}"`);
stats.transformations.sizeNormalized++;
modified = true;
}
// 5. Handle square → iconOnly
if (hasSquare) {
tagContent = tagContent.replace(/\s*\b:?square(?:=["']true["'])?(?=\s|\/?>)/, '');
// Add iconOnly attribute
tagContent = tagContent.replace(
/(N8nButton|n8n-button|N8nIconButton|n8n-icon-button|IconButton)/,
'$1 iconOnly',
);
changes.push('square → iconOnly');
stats.transformations.squareToIconOnly++;
modified = true;
}
// 6. Handle nativeType → type
if (hasNativeType) {
const nativeTypeValue = hasNativeType[1];
tagContent = tagContent.replace(/\s*\b:?nativeType=["'][^"']+["']/, '');
tagContent = tagContent.replace(
/(N8nButton|n8n-button|N8nIconButton|n8n-icon-button|IconButton)/,
`$1 type="${nativeTypeValue}"`,
);
changes.push(`nativeType="${nativeTypeValue}" → type="${nativeTypeValue}"`);
stats.transformations.nativeTypeToType++;
modified = true;
}
// 7. Handle block → style="width: 100%"
if (hasBlock) {
tagContent = tagContent.replace(/\s*\b:?block(?:=["']true["'])?(?=\s|\/?>)/, '');
addStyle = 'width: 100%';
changes.push('block → style="width: 100%"');
stats.transformations.blockToStyle++;
modified = true;
}
// 8. Handle element="a" → remove (href determines element)
if (hasElement) {
tagContent = tagContent.replace(/\s*\b:?element=["'][^"']+["']/, '');
changes.push('element removed (href determines element)');
stats.transformations.elementRemoved++;
modified = true;
}
if (!modified) {
return fullMatch;
}
// Now apply the collected changes
const BUTTON_TAG_PATTERN = /(N8nButton|n8n-button|N8nIconButton|n8n-icon-button|IconButton)/;
// Add variant attribute
if (newVariant) {
tagContent = tagContent.replace(BUTTON_TAG_PATTERN, `$1 variant="${newVariant}"`);
}
// Merge class attribute
if (addClass) {
if (hasClass) {
// Merge with existing class
tagContent = tagContent.replace(/\bclass=["']([^"']+)["']/, `class="$1 ${addClass}"`);
} else {
// Add new class attribute
tagContent = tagContent.replace(BUTTON_TAG_PATTERN, `$1 class="${addClass}"`);
}
}
// Merge style attribute
if (addStyle) {
if (hasStyle) {
// Merge with existing style
const existingStyle = hasStyle[1].trim();
const separator = existingStyle.endsWith(';') ? ' ' : '; ';
tagContent = tagContent.replace(
/\bstyle=["']([^"']+)["']/,
`style="$1${separator}${addStyle}"`,
);
} else {
// Add new style attribute
tagContent = tagContent.replace(BUTTON_TAG_PATTERN, `$1 style="${addStyle}"`);
}
}
// Build the new tag
let result;
if (selfClosing) {
result = `<${tagContent.trim()} />`;
} else {
result = `<${tagContent.trim()}>${content || ''}${closingTag}`;
}
// Log the transformation
console.log(` ${changes.join(', ')}`);
return result;
}
/**
* Transform all N8nButton usages in a file
*/
function transformFile(filePath) {
const content = readFileSync(filePath, 'utf-8');
stats.filesScanned++;
// Find template section - use greedy match ([\s\S]*) to get the outermost </template>
// Vue SFC files may have nested <template> tags for slots, so we need the last closing tag
const templateMatch = /<template[^>]*>([\s\S]*)<\/template>/i.exec(content);
if (!templateMatch) {
return { modified: false };
}
const templateStart = templateMatch.index;
const templateContent = templateMatch[1];
// Match button components (both self-closing and with content)
// Includes: N8nButton, n8n-button, N8nIconButton, n8n-icon-button, IconButton
const BUTTON_TAGS = 'N8nButton|n8n-button|N8nIconButton|n8n-icon-button|IconButton';
let newTemplateContent = templateContent;
let hasChanges = false;
// Process self-closing buttons first
// Pattern matches: <TAG + attributes (no unquoted >) + />
// This avoids matching non-self-closing tags like <N8nButton ...>content</N8nButton>
newTemplateContent = newTemplateContent.replace(
new RegExp(`<(${BUTTON_TAGS})((?:[^>"]|"[^"]*")*)\\s*\\/>`, 'gi'),
(match, tagName, attrs) => {
// Skip if no attributes
if (!attrs || !attrs.trim()) return match;
const result = transformButtonTag(match, `${tagName} ${attrs.trim()}`, true, null, null);
if (result !== match) hasChanges = true;
return result;
},
);
// Process buttons with content (non-self-closing)
// Pattern matches: <TAG + attrs (not ending with /) + > + content + </TAG>
// The attrs pattern uses negative lookahead to ensure / is not followed by >
newTemplateContent = newTemplateContent.replace(
new RegExp(
`<(${BUTTON_TAGS})((?:[^>"/]|"[^"]*"|/(?!>))*)>([\\s\\S]*?)<\\/(${BUTTON_TAGS})>`,
'gi',
),
(match, tagName, attrs, content, closeTag) => {
const result = transformButtonTag(
match,
`${tagName}${attrs || ''}`,
false,
content,
`</${closeTag}>`,
);
if (result !== match) hasChanges = true;
return result;
},
);
if (!hasChanges) {
return { modified: false };
}
// Reconstruct the file
const newContent =
content.slice(0, templateStart) +
'<template>' +
newTemplateContent +
'</template>' +
content.slice(templateStart + templateMatch[0].length);
return { modified: true, content: newContent };
}
/**
* Main function
*/
async function main() {
console.log('Button V2 Migration Codemod');
console.log('===========================');
console.log(`Mode: ${DRY_RUN ? 'DRY RUN (no files will be modified)' : 'LIVE'}`);
console.log(`Scanning: ${FRONTEND_ROOT}`);
console.log('');
const vueFiles = findVueFiles(FRONTEND_ROOT);
console.log(`Found ${vueFiles.length} Vue files\n`);
for (const filePath of vueFiles) {
const relativePath = relative(FRONTEND_ROOT, filePath);
try {
const result = transformFile(filePath);
if (result.modified) {
console.log(`\nModified: ${relativePath}`);
stats.filesModified++;
if (!DRY_RUN) {
writeFileSync(filePath, result.content, 'utf-8');
}
}
} catch (error) {
console.error(`Error processing ${relativePath}:`, error.message);
}
}
// Print summary
console.log('\n===========================');
console.log('Summary');
console.log('===========================');
console.log(`Files scanned: ${stats.filesScanned}`);
console.log(`Files modified: ${stats.filesModified}`);
console.log('');
console.log('Transformations:');
console.log(` type → variant: ${stats.transformations.typeToVariant}`);
console.log(` legacy type + class: ${stats.transformations.legacyTypeWithClass}`);
console.log(` outline → variant: ${stats.transformations.outlineToVariant}`);
console.log(` text → variant: ${stats.transformations.textToVariant}`);
console.log(` size normalized: ${stats.transformations.sizeNormalized}`);
console.log(` square → iconOnly: ${stats.transformations.squareToIconOnly}`);
console.log(` nativeType → type: ${stats.transformations.nativeTypeToType}`);
console.log(` block → style: ${stats.transformations.blockToStyle}`);
console.log(` element removed: ${stats.transformations.elementRemoved}`);
if (DRY_RUN) {
console.log('\nDry run complete. No files were modified.');
console.log('Run without --dry-run to apply changes.');
}
}
main().catch(console.error);
@@ -545,8 +545,8 @@ defineExpose({
>
<N8nButton
v-if="opt.text"
type="secondary"
size="mini"
variant="subtle"
size="xsmall"
@click="() => onQuickReply(opt)"
>
{{ opt.text }}
@@ -284,34 +284,24 @@ exports[`AskAssistantChat > renders chat with messages correctly 1`] = `
data-test-id="quick-replies"
>
<n8n-button-stub
active="false"
block="false"
class=""
disabled="false"
element="button"
label=""
icononly="false"
loading="false"
outline="false"
size="mini"
square="false"
text="false"
type="secondary"
size="xsmall"
variant="subtle"
/>
</div>
<div
data-test-id="quick-replies"
>
<n8n-button-stub
active="false"
block="false"
class=""
disabled="false"
element="button"
label=""
icononly="false"
loading="false"
outline="false"
size="mini"
square="false"
text="false"
type="secondary"
size="xsmall"
variant="subtle"
/>
</div>
@@ -26,9 +26,9 @@ const { t } = useI18n();
{{ message.content }}
</p>
<N8nButton
variant="subtle"
v-if="message.retry"
type="secondary"
size="mini"
size="xsmall"
:class="$style.retryButton"
data-test-id="error-retry-button"
@click="() => message.retry?.()"
@@ -67,7 +67,7 @@ function onCancelFeedback() {
<div v-if="showRatingButtons" :class="$style.buttons">
<template v-if="!minimal">
<N8nButton
type="secondary"
variant="subtle"
size="small"
:label="t('assistantChat.builder.thumbsUp')"
data-test-id="message-thumbs-up-button"
@@ -75,7 +75,7 @@ function onCancelFeedback() {
@click="onRateButton('up')"
/>
<N8nButton
type="secondary"
variant="subtle"
size="small"
data-test-id="message-thumbs-down-button"
:label="t('assistantChat.builder.thumbsDown')"
@@ -85,9 +85,8 @@ function onCancelFeedback() {
</template>
<template v-else>
<N8nIconButton
type="tertiary"
variant="ghost"
size="small"
text
icon="thumbs-up"
icon-size="large"
:class="$style.ratingButton"
@@ -95,9 +94,8 @@ function onCancelFeedback() {
@click="onRateButton('up')"
/>
<N8nIconButton
type="tertiary"
variant="ghost"
size="small"
text
icon="thumbs-down"
icon-size="large"
:class="$style.ratingButton"
@@ -120,13 +118,13 @@ function onCancelFeedback() {
/>
<div :class="$style.feedbackActions">
<N8nButton
type="secondary"
variant="subtle"
size="small"
:label="t('generic.cancel')"
@click="onCancelFeedback"
/>
<N8nButton
type="primary"
variant="solid"
size="small"
data-test-id="message-submit-feedback-button"
:label="t('assistantChat.builder.feedbackSubmit')"
@@ -79,7 +79,7 @@ function handleConfirm() {
{{ t('aiAssistant.versionCard.restoreModal.showVersion') }}
<N8nIcon icon="arrow-up-right" size="xlarge" />
</button>
<N8nButton type="primary" size="large" @click="handleConfirm">
<N8nButton variant="solid" size="large" @click="handleConfirm">
{{ t('aiAssistant.versionCard.restoreModal.restore') }}
</N8nButton>
</div>
@@ -145,9 +145,8 @@ async function onCopyButtonClick(content: string, e: MouseEvent) {
>
<header v-if="isClipboardSupported">
<N8nButton
type="tertiary"
:text="true"
size="mini"
variant="ghost"
size="xsmall"
data-test-id="assistant-copy-snippet-button"
@click="onCopyButtonClick(message.codeSnippet, $event)"
>
@@ -3,8 +3,8 @@
exports[`MessageRating > should render correctly with default props 1`] = `
"<div class="rating">
<div class="buttons">
<n8n-button-stub icon="thumbs-up" block="false" element="button" label="assistantChat.builder.thumbsUp" square="false" active="false" disabled="false" loading="false" outline="false" size="small" text="false" type="secondary" data-test-id="message-thumbs-up-button"></n8n-button-stub>
<n8n-button-stub icon="thumbs-down" block="false" element="button" label="assistantChat.builder.thumbsDown" square="false" active="false" disabled="false" loading="false" outline="false" size="small" text="false" type="secondary" data-test-id="message-thumbs-down-button"></n8n-button-stub>
<n8n-button-stub label="assistantChat.builder.thumbsUp" icon="thumbs-up" variant="subtle" size="small" loading="false" icononly="false" disabled="false" class="" data-test-id="message-thumbs-up-button"></n8n-button-stub>
<n8n-button-stub label="assistantChat.builder.thumbsDown" icon="thumbs-down" variant="subtle" size="small" loading="false" icononly="false" disabled="false" class="" data-test-id="message-thumbs-down-button"></n8n-button-stub>
</div>
<!--v-if-->
<!--v-if-->
@@ -34,8 +34,8 @@ const $style = useCssModule();
v-if="showStop"
:class="$style.stopButton"
:label="'Stop'"
type="secondary"
size="mini"
variant="subtle"
size="xsmall"
@click="emit('stop')"
/>
</N8nCanvasPill>
@@ -113,8 +113,8 @@ const diffs = computed(() => {
</div>
<div v-else-if="replaced">
<N8nButton
type="secondary"
size="mini"
variant="subtle"
size="xsmall"
icon="undo-2"
data-test-id="undo-replace-button"
@click="() => emit('undo')"
@@ -128,8 +128,8 @@ const diffs = computed(() => {
</div>
<N8nButton
v-else
:type="replacing ? 'secondary' : 'primary'"
size="mini"
:variant="replacing ? 'subtle' : 'solid'"
size="xsmall"
icon="refresh-cw"
data-test-id="replace-code-button"
:disabled="!content || streaming"
@@ -271,19 +271,14 @@ exports[`CodeDiff > renders code diff correctly 1`] = `
class="actions"
>
<n8n-button-stub
active="false"
block="false"
class=""
data-test-id="replace-code-button"
disabled="false"
element="button"
icon="refresh-cw"
label=""
icononly="false"
loading="false"
outline="false"
size="mini"
square="false"
text="false"
type="primary"
size="xsmall"
variant="solid"
/>
</div>
</div>
@@ -808,19 +803,14 @@ exports[`CodeDiff > renders replaced code diff correctly 1`] = `
>
<div>
<n8n-button-stub
active="false"
block="false"
class=""
data-test-id="undo-replace-button"
disabled="false"
element="button"
icon="undo-2"
label=""
icononly="false"
loading="false"
outline="false"
size="mini"
square="false"
text="false"
type="secondary"
size="xsmall"
variant="subtle"
/>
<n8n-icon-stub
class="ml-xs"
@@ -1090,19 +1080,14 @@ exports[`CodeDiff > renders replacing code diff correctly 1`] = `
class="actions"
>
<n8n-button-stub
active="false"
block="false"
class=""
data-test-id="replace-code-button"
disabled="false"
element="button"
icon="refresh-cw"
label=""
icononly="false"
loading="true"
outline="false"
size="mini"
square="false"
text="false"
type="secondary"
size="xsmall"
variant="subtle"
/>
</div>
</div>
@@ -27,12 +27,10 @@ describe('N8nDateRangePicker', () => {
});
it('should emit update:close when clicking the apply button', async () => {
const { container, emitted, getByText } = render(DateRangePicker);
const { container, emitted, getByRole } = render(DateRangePicker);
await openCalendarPopover(container);
getByText('Apply', { selector: 'button' }); // ensure the button is in the
const applyButton = getByText('Apply', { selector: 'button' });
const applyButton = getByRole('button', { name: 'Apply' });
expect(applyButton).toBeVisible();
await userEvent.click(applyButton);
@@ -18,7 +18,7 @@ import {
useForwardPropsEmits,
} from 'reka-ui';
import Button from '../N8nButton/Button.vue';
import N8nButton from '../N8nButton';
import IconButton from '../N8nIconButton';
import N8nDateRangePickerField from './DateRangePickerField.vue';
import type { N8nDateRangePickerProps, N8nDateRangePickerRootEmits } from './index';
@@ -44,7 +44,7 @@ const forwarded = useForwardPropsEmits(props, emit);
<DateRangePickerRoot v-bind="forwarded">
<DateRangePickerTrigger as-child>
<slot name="trigger">
<IconButton icon="calendar" type="secondary" aria-label="Open calendar" />
<IconButton variant="subtle" icon="calendar" aria-label="Open calendar" />
</slot>
</DateRangePickerTrigger>
@@ -59,11 +59,11 @@ const forwarded = useForwardPropsEmits(props, emit);
<div :class="$style.CalendarWrapper">
<DateRangePickerHeader :class="$style.CalendarHeader">
<DateRangePickerPrev as-child>
<IconButton icon="chevron-left" type="secondary" />
<IconButton icon="chevron-left" variant="subtle" />
</DateRangePickerPrev>
<DateRangePickerHeading :class="$style.CalendarHeading" />
<DateRangePickerNext as-child>
<IconButton icon="chevron-right" type="secondary" />
<IconButton icon="chevron-right" variant="subtle" />
</DateRangePickerNext>
</DateRangePickerHeader>
@@ -110,9 +110,13 @@ const forwarded = useForwardPropsEmits(props, emit);
<N8nDateRangePickerField :class="$style.DateField"></N8nDateRangePickerField>
<div :class="$style.DateFieldError">Outside of allowed range</div>
<Button type="secondary" block class="mt-2xs" @click="emit('update:open', false)">
Apply
</Button>
<N8nButton
variant="subtle"
label="Apply"
class="mt-2xs"
:class="$style.ApplyButton"
@click="emit('update:open', false)"
/>
</div>
</div>
</DateRangePickerCalendar>
@@ -138,6 +142,10 @@ const forwarded = useForwardPropsEmits(props, emit);
display: none;
}
.ApplyButton {
width: 100%;
}
.DateFieldSegment:focus {
outline: 2px solid rgba(67, 142, 255, 1);
border-radius: 0.25rem;
@@ -11,7 +11,7 @@ describe('N8NActionBox', () => {
description:
'Long description that you should know something is the way it is because of how it is. ',
buttonText: 'Do something',
buttonType: 'primary',
buttonVariant: 'solid',
},
global: {
stubs: ['N8nHeading', 'N8nText', 'N8nButton', 'N8nCallout', 'N8nTooltip'],
@@ -27,7 +27,7 @@ describe('N8NActionBox', () => {
heading: 'Add something new',
description: 'Click the button to add a new item.',
buttonText: 'Add Item',
buttonType: 'primary',
buttonVariant: 'solid',
},
global: {
stubs: ['N8nHeading', 'N8nText', 'N8nButton', 'N8nCallout', 'N8nIcon', 'N8nTooltip'],
@@ -1,5 +1,5 @@
<script lang="ts" setup>
import type { ButtonType } from '../../types/button';
import type { ButtonVariant } from '../../types/button';
import N8nButton from '../N8nButton';
import N8nCallout, { type CalloutTheme } from '../N8nCallout';
import N8nHeading from '../N8nHeading';
@@ -13,7 +13,7 @@ interface ActionBoxProps {
icon?: IconOrEmoji;
heading?: string;
buttonText?: string;
buttonType?: ButtonType;
buttonVariant?: ButtonVariant;
buttonDisabled?: boolean;
buttonIcon?: IconName;
description?: string;
@@ -59,7 +59,7 @@ withDefaults(defineProps<ActionBoxProps>(), {
</template>
<N8nButton
:label="buttonText"
:type="buttonType"
:variant="buttonVariant"
:disabled="buttonDisabled"
:icon="buttonIcon"
size="large"
@@ -104,9 +104,8 @@ defineExpose({ open, close });
>
<slot v-if="$slots.activator" name="activator" />
<N8nIconButton
variant="ghost"
v-else
type="tertiary"
text
:class="$style.activator"
:size="activatorSize"
:icon="activatorIcon"
@@ -221,7 +221,7 @@ describe('N8nAlertDialog', () => {
});
describe('actionVariant', () => {
it('should default to solid variant (primary button)', async () => {
it('should default to solid variant', async () => {
const user = userEvent.setup();
const { getByTestId, getByRole } = renderAlertDialog({
title: 'Save?',
@@ -230,10 +230,10 @@ describe('N8nAlertDialog', () => {
await user.click(getByTestId('alert-trigger'));
const actionButton = getByRole('button', { name: 'Confirm' });
expect(actionButton.className).toContain('primary');
expect(actionButton.className).toContain('solid');
});
it('should apply destructive variant (danger button)', async () => {
it('should apply destructive variant', async () => {
const user = userEvent.setup();
const { getByTestId, getByRole } = renderAlertDialog({
title: 'Delete?',
@@ -243,7 +243,7 @@ describe('N8nAlertDialog', () => {
await user.click(getByTestId('alert-trigger'));
const actionButton = getByRole('button', { name: 'Confirm' });
expect(actionButton.className).toContain('danger');
expect(actionButton.className).toContain('destructive');
});
});
@@ -109,9 +109,9 @@ const handleCancel = () => {
<slot />
<N8nDialogFooter>
<N8nButton type="secondary" :label="cancelLabel" @click="handleCancel" />
<N8nButton variant="subtle" :label="cancelLabel" @click="handleCancel" />
<N8nButton
:type="actionVariant === 'destructive' ? 'danger' : 'primary'"
:variant="actionVariant === 'destructive' ? 'destructive' : 'solid'"
:label="actionLabel"
:loading="loading"
@click="handleAction"
@@ -0,0 +1,48 @@
/**
* Legacy Button Override Classes
*
* These global classes can be applied via the `class` attribute to override
* V2 button styling with legacy color schemes.
*
* @deprecated Do not use these classes in new code. They exist only for
* backwards compatibility during migration from V1 to V2 Button API.
* These will be removed in a future major version.
*
* Usage:
* <N8nButton variant="solid" class="n8n-button--success">Save</N8nButton>
* <N8nButton variant="ghost" class="n8n-button--highlight">Info</N8nButton>
*/
/** @deprecated Use a semantic variant instead of success color */
:global(.n8n-button--success) {
--button--color--background: var(--color--success);
--button--color--background-hover: var(--color--success--shade-1);
--button--color--background-active: var(--color--success--shade-1);
--button--color: var(--color--neutral-white);
--button--shadow: 0 0 0 1px var(--color--success);
--button--shadow--hover: 0 0 0 1px var(--color--success--shade-1);
--button--shadow--active: 0 0 0 1px var(--color--success--shade-1);
&:disabled,
&[aria-disabled='true'] {
--button--color--background: var(--color--success--tint-3);
--button--shadow: 0 0 0 1px var(--color--success--tint-3);
}
}
/** @deprecated Use a semantic variant instead of warning color */
:global(.n8n-button--warning) {
--button--color--background: var(--color--warning);
--button--color--background-hover: var(--color--warning--shade-1);
--button--color--background-active: var(--color--warning--shade-1);
--button--color: var(--color--neutral-white);
--button--shadow: 0 0 0 1px var(--color--warning);
--button--shadow--hover: 0 0 0 1px var(--color--warning--shade-1);
--button--shadow--active: 0 0 0 1px var(--color--warning--shade-1);
&:disabled,
&[aria-disabled='true'] {
--button--color--background: var(--color--warning--tint-1);
--button--shadow: 0 0 0 1px var(--color--warning--tint-1);
}
}
@@ -1,285 +0,0 @@
@use '../../css/mixins/utils';
@use '../../css/common/var';
@use 'sass:string';
@mixin n8n-button($override: false) {
$important: if($override, !important, '');
display: inline-block;
line-height: 1;
white-space: nowrap;
cursor: pointer;
border: var(--border-width) var.$button-border-color var(--border-style)
string.unquote($important);
color: var.$button-font-color string.unquote($important);
background-color: var.$button-background-color string.unquote($important);
font-weight: var(--font-weight--medium) string.unquote($important);
border-radius: var.$button-border-radius string.unquote($important);
padding: var.$button-padding-vertical var.$button-padding-horizontal string.unquote($important);
font-size: var.$button-font-size string.unquote($important);
-webkit-appearance: none;
text-align: center;
box-sizing: border-box;
outline: none;
margin: 0;
transition:
all 0.3s,
padding 0s,
width 0s,
height 0s;
gap: var(--spacing--3xs);
@include utils.utils-user-select(none);
// Solution for a inside button
& a {
color: var.$button-font-color string.unquote($important);
}
&:hover {
color: var.$button-hover-font-color string.unquote($important);
border-color: var.$button-hover-border-color string.unquote($important);
background-color: var.$button-hover-background-color string.unquote($important);
& a {
color: var.$button-hover-font-color string.unquote($important);
}
}
&:active,
&.active {
color: var.$button-active-font-color string.unquote($important);
border-color: var.$button-active-border-color string.unquote($important);
background-color: var.$button-active-background-color string.unquote($important);
outline: none;
& a {
color: var.$button-active-font-color string.unquote($important);
}
}
&:focus-visible:not(:active, .active) {
color: var.$button-focus-font-color string.unquote($important);
border-color: var.$button-focus-border-color string.unquote($important);
background-color: var.$button-focus-background-color string.unquote($important);
outline: var.$focus-outline-width solid var.$button-focus-outline-color
string.unquote($important);
& a {
color: var.$button-focus-font-color string.unquote($important);
}
}
&.disabled {
&,
&:hover,
&:active,
&:focus-visible {
color: var.$button-disabled-font-color;
border-color: var.$button-disabled-border-color;
background-color: var.$button-disabled-background-color;
& a {
color: var.$button-disabled-font-color;
}
}
}
.loading {
&,
&:hover,
&:active,
&:focus-visible {
color: var.$button-loading-font-color;
border-color: var.$button-loading-border-color;
background-color: var.$button-loading-background-color;
& a {
color: var.$button-loading-font-color;
}
}
}
&::-moz-focus-inner {
border: 0;
}
> i {
display: none;
}
> span {
display: flex;
justify-content: center;
align-items: center;
}
}
@mixin n8n-button-secondary {
--button--color--text: var(--button--color--text--secondary);
--button--border-color: var(--button--border-color--secondary);
--button--color--background: var(--button--color--background--secondary);
--button--color--text--hover: var(--button--color--text--secondary--hover-active-focus);
--button--border-color--hover: var(--button--border-color--secondary--hover-active-focus);
--button--color--background--hover: var(--button--color--background--secondary--hover);
--button--color--text--active: var(--button--color--text--secondary--hover-active-focus);
--button--border-color--active: var(--button--border-color--secondary--hover-active-focus);
--button--color--background--active: var(--button--color--background--secondary--active-focus);
--button--color--text--focus: var(--button--color--text--secondary--hover-active-focus);
--button--border-color--focus: var(--button--border-color--secondary--hover-active-focus);
--button--color--background--focus: var(--button--color--background--secondary--active-focus);
--button--outline-color--focus: var(--button--outline-color--secondary--focus);
--button--color--text--disabled: var(--button--color--text--secondary--disabled);
--button--border-color--disabled: var(--button--border-color--secondary--disabled);
--button--color--background--disabled: var(--button--color--background--secondary);
--button--color--text--loading: var(--button--color--text--secondary--loading);
--button--border-color--loading: var(--button--border-color--secondary--loading);
--button--color--background--loading: var(--button--color--background--secondary--loading);
}
@mixin n8n-button-highlight {
--button--color--text: var(--button--color--text--highlight);
--button--border-color: var(--button--border-color--highlight);
--button--color--background: var(--button--color--background--highlight);
--button--color--text--hover: var(--button--color--text--highlight--hover-active-focus);
--button--border-color--hover: var(--button--border-color--highlight--hover-active-focus);
--button--color--background--hover: var(--button--color--background--highlight--hover);
--button--color--text--active: var(--button--color--text--highlight--hover-active-focus);
--button--border-color--active: var(--button--border-color--highlight--hover-active-focus);
--button--color--background--active: var(--button--color--background--highlight--active-focus);
--button--color--text--focus: var(--button--color--text--highlight--hover-active-focus);
--button--border-color--focus: var(--button--border-color--highlight--hover-active-focus);
--button--color--background--focus: var(--button--color--background--highlight--active-focus);
--button--outline-color--focus: var(--button--outline-color--highlight--focus);
--button--color--text--disabled: var(--button--color--text--highlight--disabled);
--button--border-color--disabled: var(--button--border-color--highlight--disabled);
--button--color--background--disabled: var(--button--color--background--highlight--disabled);
--button--color--text--loading: var(--button--color--text--highlight--loading);
--button--border-color--loading: var(--button--border-color--highlight--loading);
--button--color--background--loading: var(--button--color--background--highlight--loading);
}
@mixin n8n-button-highlight-fill {
--button--color--text: var(--button--color--text--highlight-fill);
--button--border-color: var(--button--border-color--highlight-fill);
--button--color--background: var(--button--color--background--highlight-fill);
--button--color--text--hover: var(--button--color--text--highlight-fill--hover-active-focus);
--button--border-color--hover: var(--button--border-color--highlight-fill--hover-active-focus);
--button--color--background--hover: var(--button--color--background--highlight-fill--hover);
--button--color--text--active: var(--button--color--text--highlight-fill--hover-active-focus);
--button--border-color--active: var(--button--border-color--highlight-fill--hover-active-focus);
--button--color--background--active: var(
--button--color--background--highlight-fill--active-focus
);
--button--color--text--focus: var(--button--color--text--highlight-fill--hover-active-focus);
--button--border-color--focus: var(--button--border-color--highlight-fill--hover-active-focus);
--button--color--background--focus: var(
--button--color--background--highlight-fill--active-focus
);
--button--outline-color--focus: var(--button--outline-color--highlight-fill--focus);
--button--color--text--disabled: var(--button--color--text--highlight-fill--disabled);
--button--border-color--disabled: var(--button--border-color--highlight-fill--disabled);
--button--color--background--disabled: var(--button--color--background--highlight-fill--disabled);
--button--color--text--loading: var(--button--color--text--highlight-fill);
--button--border-color--loading: var(--button--border-color--highlight-fill);
--button--color--background--loading: var(--button--color--background--highlight-fill);
}
@mixin n8n-button-success {
--button--color--text: var(--button--color--text--success);
--button--border-color: var(--color--success);
--button--color--background: var(--color--success);
--button--color--text--hover: var(--button--color--text--success);
--button--border-color--hover: var(--color--success--shade-1);
--button--color--background--hover: var(--color--success--shade-1);
--button--color--text--active: var(--button--color--text--success);
--button--border-color--active: var(--color--success--shade-1);
--button--color--background--active: var(--color--success--shade-1);
--button--color--text--focus: var(--button--color--text--success);
--button--border-color--focus: var(--color--success);
--button--color--background--focus: var(--color--success);
--button--outline-color--focus: var(--color--success--tint-1);
--button--color--text--disabled: var(--button--color--text--success--disabled);
--button--border-color--disabled: var(--color--success--tint-3);
--button--color--background--disabled: var(--color--success--tint-3);
--button--color--text--loading: var(--button--color--text--success);
--button--border-color--loading: var(--color--success);
--button--color--background--loading: var(--color--success);
}
@mixin n8n-button-warning {
--button--color--text: var(--button--color--text--warning);
--button--border-color: var(--color--warning);
--button--color--background: var(--color--warning);
--button--color--text--hover: var(--button--color--text--warning);
--button--border-color--hover: var(--color--warning--shade-1);
--button--color--background--hover: var(--color--warning--shade-1);
--button--color--text--active: var(--button--color--text--warning);
--button--border-color--active: var(--color--warning--shade-1);
--button--color--background--active: var(--color--warning--shade-1);
--button--color--text--focus: var(--button--color--text--warning);
--button--border-color--focus: var(--color--warning);
--button--color--background--focus: var(--color--warning);
--button--outline-color--focus: var(--color--warning--tint-1);
--button--color--text--disabled: var(--button--color--text--warning--disabled);
--button--border-color--disabled: var(--color--warning--tint-1);
--button--color--background--disabled: var(--color--warning--tint-1);
--button--color--text--loading: var(--button--color--text--warning);
--button--border-color--loading: var(--color--warning);
--button--color--background--loading: var(--color--warning);
}
@mixin n8n-button-danger {
--button--color--text: var(--button--color--text--danger);
--button--border-color: var(--button--border-color--danger);
--button--color--background: var(--color--danger);
--button--color--text--hover: var(--button--color--text--danger);
--button--border-color--hover: var(--color--danger--shade-1);
--button--color--background--hover: var(--color--danger--shade-1);
--button--color--text--active: var(--button--color--text--danger);
--button--border-color--active: var(--color--danger--shade-1);
--button--color--background--active: var(--color--danger--shade-1);
--button--color--text--focus: var(--button--color--text--danger);
--button--border-color--focus: var(--color--danger);
--button--color--background--focus: var(--color--danger);
--button--outline-color--focus: var(--button--outline-color--danger--focus);
--button--color--text--disabled: var(--button--color--text--danger--disabled);
--button--border-color--disabled: var(--button--border-color--danger--disabled);
--button--color--background--disabled: var(--button--color--background--danger--disabled);
--button--color--text--loading: var(--button--color--text--danger);
--button--border-color--loading: var(--color--danger);
--button--color--background--loading: var(--color--danger);
}
@@ -1,183 +1,259 @@
import type { StoryFn } from '@storybook/vue3-vite';
import { action } from 'storybook/actions';
import type { Meta, StoryObj } from '@storybook/vue3-vite';
import N8nButton from './Button.vue';
import N8nIcon from '../N8nIcon/Icon.vue';
export default {
const meta = {
title: 'Atoms/Button',
component: N8nButton,
argTypes: {
type: {
variant: {
control: 'select',
options: [
'primary',
'secondary',
'tertiary',
'success',
'warning',
'danger',
'highlight',
'highlightFill',
],
options: ['solid', 'subtle', 'ghost', 'outline', 'destructive'],
},
size: {
control: {
type: 'select',
},
options: ['mini', 'small', 'medium', 'large', 'xlarge'],
control: 'select',
options: ['xsmall', 'small', 'medium', 'large', 'xlarge'],
},
float: {
type: 'select',
options: ['left', 'right'],
loading: {
control: 'boolean',
},
disabled: {
control: 'boolean',
},
iconOnly: {
control: 'boolean',
description: 'Makes button square (icon-only)',
},
href: {
control: 'text',
description: 'If provided, renders as a link',
},
// Hide internal props from the table
icon: { table: { disable: true } },
iconSize: { table: { disable: true } },
label: { table: { disable: true } },
},
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/file/DxLbnIyMK8X0uLkUguFV4n/n8n-design-system_v1?node-id=5%3A1147',
docs: {
source: { type: 'dynamic' },
},
},
};
} satisfies Meta<typeof N8nButton>;
const methods = {
onClick: action('click'),
};
export default meta;
type Story = StoryObj<typeof meta>;
const Template: StoryFn = (args, { argTypes }) => ({
setup: () => ({ args }),
props: Object.keys(argTypes),
components: {
N8nButton,
export const Default: Story = {
render: (args) => ({
components: { N8nButton },
setup() {
return { args };
},
template: `
<div style="display: grid; place-items: center;">
<N8nButton v-bind="args">{{ args.default || 'Button' }}</N8nButton>
</div>
`,
}),
args: {
variant: 'solid',
size: 'medium',
loading: false,
default: 'Button',
},
template: '<n8n-button v-bind="args" @click="onClick" />',
methods,
});
export const Button = Template.bind({});
Button.args = {
label: 'Button',
};
const AllSizesTemplate: StoryFn = (args, { argTypes }) => ({
setup: () => ({ args }),
props: Object.keys(argTypes),
components: {
N8nButton,
},
template: `<div>
<n8n-button v-bind="args" size="large" @click="onClick" />
<n8n-button v-bind="args" size="medium" @click="onClick" />
<n8n-button v-bind="args" size="small" @click="onClick" />
<n8n-button v-bind="args" :loading="true" @click="onClick" />
<n8n-button v-bind="args" :disabled="true" @click="onClick" />
</div>`,
methods,
});
const AllColorsAndSizesTemplate: StoryFn = (args, { argTypes }) => ({
setup: () => ({ args }),
props: Object.keys(argTypes),
components: {
N8nButton,
},
template: `<div>
<n8n-button v-bind="args" size="large" type="primary" @click="onClick" />
<n8n-button v-bind="args" size="large" type="secondary" @click="onClick" />
<n8n-button v-bind="args" size="large" type="tertiary" @click="onClick" />
<n8n-button v-bind="args" size="large" type="success" @click="onClick" />
<n8n-button v-bind="args" size="large" type="warning" @click="onClick" />
<n8n-button v-bind="args" size="large" type="danger" @click="onClick" />
<n8n-button v-bind="args" size="large" type="highlight" @click="onClick" />
<br/>
<br/>
<n8n-button v-bind="args" size="medium" type="primary" @click="onClick" />
<n8n-button v-bind="args" size="medium" type="secondary" @click="onClick" />
<n8n-button v-bind="args" size="medium" type="tertiary" @click="onClick" />
<n8n-button v-bind="args" size="medium" type="success" @click="onClick" />
<n8n-button v-bind="args" size="medium" type="warning" @click="onClick" />
<n8n-button v-bind="args" size="medium" type="danger" @click="onClick" />
<n8n-button v-bind="args" size="medium" type="highlight" @click="onClick" />
<br/>
<br/>
<n8n-button v-bind="args" size="small" type="primary" @click="onClick" />
<n8n-button v-bind="args" size="small" type="secondary" @click="onClick" />
<n8n-button v-bind="args" size="small" type="tertiary" @click="onClick" />
<n8n-button v-bind="args" size="small" type="success" @click="onClick" />
<n8n-button v-bind="args" size="small" type="warning" @click="onClick" />
<n8n-button v-bind="args" size="small" type="danger" @click="onClick" />
<n8n-button v-bind="args" size="small" type="highlight" @click="onClick" />
</div>`,
methods,
});
export const Primary = AllSizesTemplate.bind({});
Primary.args = {
type: 'primary',
label: 'Button',
export const Variant: Story = {
render: () => ({
components: { N8nButton },
template: `
<div style="display: grid; place-items: center;">
<div style="display: flex; gap: 12px; align-items: center;">
<N8nButton variant="solid" size="medium">Solid</N8nButton>
<N8nButton variant="subtle" size="medium">Subtle</N8nButton>
<N8nButton variant="outline" size="medium">Outline</N8nButton>
<N8nButton variant="ghost" size="medium">Ghost</N8nButton>
<N8nButton variant="destructive" size="medium">Destructive</N8nButton>
</div>
</div>
`,
}),
args: {},
};
export const Secondary = AllSizesTemplate.bind({});
Secondary.args = {
type: 'secondary',
label: 'Button',
export const Size: Story = {
render: () => ({
components: { N8nButton },
template: `
<div style="display: grid; place-items: center;">
<div style="display: flex; gap: 12px; align-items: center;">
<N8nButton variant="solid" size="xsmall">XSmall</N8nButton>
<N8nButton variant="solid" size="small">Small</N8nButton>
<N8nButton variant="solid" size="medium">Medium</N8nButton>
<N8nButton variant="solid" size="large">Large</N8nButton>
<N8nButton variant="solid" size="xlarge">XLarge</N8nButton>
</div>
</div>
`,
}),
args: {},
};
export const Tertiary = AllSizesTemplate.bind({});
Tertiary.args = {
type: 'tertiary',
label: 'Button',
export const WithIcons: Story = {
render: () => ({
components: { N8nButton, N8nIcon },
template: `
<div style="display: grid; place-items: center;">
<div style="display: flex; gap: 12px; align-items: center;">
<N8nButton variant="solid" size="medium">
<N8nIcon icon="plus" size="medium" />
Add Item
</N8nButton>
<N8nButton variant="solid" size="medium">
Continue
<N8nIcon icon="arrow-right" size="medium" />
</N8nButton>
<N8nButton variant="solid" size="medium">
<N8nIcon icon="plus" size="medium" />
Options
<N8nIcon icon="chevron-down" size="medium" />
</N8nButton>
</div>
</div>
`,
}),
args: {},
};
export const Success = AllSizesTemplate.bind({});
Success.args = {
type: 'success',
label: 'Button',
export const Loading: Story = {
render: () => ({
components: { N8nButton },
template: `
<div style="display: grid; place-items: center;">
<div style="display: flex; gap: 12px; align-items: center;">
<N8nButton variant="solid" size="medium" loading>Solid</N8nButton>
<N8nButton variant="subtle" size="medium" loading>Subtle</N8nButton>
<N8nButton variant="outline" size="medium" loading>Outline</N8nButton>
<N8nButton variant="ghost" size="medium" loading>Ghost</N8nButton>
<N8nButton variant="destructive" size="medium" loading>Destructive</N8nButton>
</div>
</div>
`,
}),
args: {},
};
export const Warning = AllSizesTemplate.bind({});
Warning.args = {
type: 'warning',
label: 'Button',
export const Link: Story = {
render: () => ({
components: { N8nButton },
template: `
<div style="display: grid; place-items: center;">
<div style="display: flex; gap: 12px; align-items: center;">
<N8nButton variant="solid" size="medium" href="https://n8n.io">Link</N8nButton>
<N8nButton variant="subtle" size="medium" href="https://n8n.io">Link</N8nButton>
<N8nButton variant="outline" size="medium" href="https://n8n.io">Link</N8nButton>
<N8nButton variant="ghost" size="medium" href="https://n8n.io">Link</N8nButton>
<N8nButton variant="destructive" size="medium" href="https://n8n.io">Link</N8nButton>
</div>
</div>
`,
}),
args: {},
};
export const Danger = AllSizesTemplate.bind({});
Danger.args = {
type: 'danger',
label: 'Button',
export const IconOnly: Story = {
render: () => ({
components: { N8nButton, N8nIcon },
template: `
<div style="display: grid; place-items: center;">
<div style="display: flex; gap: 12px; align-items: center;">
<N8nButton variant="solid" size="xsmall" icon-only aria-label="Add">
<N8nIcon icon="plus" size="xsmall" />
</N8nButton>
<N8nButton variant="solid" size="small" icon-only aria-label="Add">
<N8nIcon icon="plus" size="small" />
</N8nButton>
<N8nButton variant="solid" size="medium" icon-only aria-label="Add">
<N8nIcon icon="plus" size="medium" />
</N8nButton>
</div>
</div>
`,
}),
args: {},
};
export const Outline = AllColorsAndSizesTemplate.bind({});
Outline.args = {
outline: true,
label: 'Button',
export const Disabled: Story = {
render: () => ({
components: { N8nButton },
template: `
<div style="display: grid; place-items: center;">
<div style="display: flex; gap: 12px; align-items: center;">
<N8nButton variant="solid" size="medium" disabled>Solid</N8nButton>
<N8nButton variant="subtle" size="medium" disabled>Subtle</N8nButton>
<N8nButton variant="outline" size="medium" disabled>Outline</N8nButton>
<N8nButton variant="ghost" size="medium" disabled>Ghost</N8nButton>
<N8nButton variant="destructive" size="medium" disabled>Destructive</N8nButton>
</div>
</div>
`,
}),
args: {},
};
export const Text = AllColorsAndSizesTemplate.bind({});
Text.args = {
text: true,
label: 'Button',
};
/**
* ## Migration from `type` to `variant`
*
* The `type` prop is deprecated. Use `variant` instead with this mapping:
*
* | Legacy `type` | Current `variant` |
* |---------------|-------------------|
* | `primary` | `solid` |
* | `secondary` | `subtle` |
* | `tertiary` | `ghost` |
* | `danger` | `destructive` |
*
* Additionally:
* - `outline` prop → `variant="outline"`
* - `text` prop → `variant="ghost"`
*/
export const TypeToVariantMapping: Story = {
render: () => ({
components: { N8nButton },
template: `
<div style="display: grid; gap: 24px;">
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; align-items: center; font-family: var(--font-family); font-size: var(--font-size--sm);">
<strong>Legacy type</strong>
<strong>Current variant</strong>
<strong>Result</strong>
export const WithIcon = AllSizesTemplate.bind({});
WithIcon.args = {
label: 'Button',
icon: 'circle-plus',
};
<code>type="primary"</code>
<code>variant="solid"</code>
<N8nButton variant="solid">Solid</N8nButton>
export const Highlight = AllSizesTemplate.bind({});
Highlight.args = {
type: 'highlight',
label: 'Button',
};
<code>type="secondary"</code>
<code>variant="subtle"</code>
<N8nButton variant="subtle">Subtle</N8nButton>
export const HighlightFill = AllSizesTemplate.bind({});
HighlightFill.args = {
type: 'highlightFill',
label: 'Button',
};
<code>type="tertiary"</code>
<code>variant="ghost"</code>
<N8nButton variant="ghost">Ghost</N8nButton>
export const Square = AllColorsAndSizesTemplate.bind({});
Square.args = {
label: '48',
square: true,
<code>type="danger"</code>
<code>variant="destructive"</code>
<N8nButton variant="destructive">Destructive</N8nButton>
<code>outline</code>
<code>variant="outline"</code>
<N8nButton variant="outline">Outline</N8nButton>
<code>text</code>
<code>variant="ghost"</code>
<N8nButton variant="ghost">Ghost</N8nButton>
</div>
</div>
`,
}),
args: {},
};
@@ -1,47 +1,231 @@
import userEvent from '@testing-library/user-event';
import { render } from '@testing-library/vue';
import N8nButton from './Button.vue';
const slots = {
default: 'Button',
};
const stubs = ['N8nSpinner', 'N8nIcon'];
describe('components', () => {
describe('N8nButton', () => {
it('should render correctly', () => {
const wrapper = render(N8nButton, {
slots,
global: {
stubs,
},
describe('rendering', () => {
it('should render correctly with default props', () => {
const wrapper = render(N8nButton, {
slots: {
default: 'Click me',
},
global: {
stubs,
},
});
expect(wrapper.getByRole('button')).toBeInTheDocument();
expect(wrapper.getByText('Click me')).toBeInTheDocument();
});
it('should render as button element by default', () => {
const wrapper = render(N8nButton, {
slots: {
default: 'Button',
},
global: {
stubs,
},
});
expect(wrapper.container.querySelector('button')).toBeInTheDocument();
});
it('should render as anchor element when href is provided', () => {
const wrapper = render(N8nButton, {
props: {
href: 'https://example.com',
},
slots: {
default: 'Link Button',
},
global: {
stubs,
},
});
const link = wrapper.container.querySelector('a');
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute('href', 'https://example.com');
expect(link).toHaveAttribute('rel', 'nofollow noopener noreferrer');
});
it('should have type="button" by default', () => {
const wrapper = render(N8nButton, {
slots: {
default: 'Button',
},
global: {
stubs,
},
});
expect(wrapper.getByRole('button')).toHaveAttribute('type', 'button');
});
it('should not have type attribute when rendered as link', () => {
const wrapper = render(N8nButton, {
props: {
href: 'https://example.com',
},
slots: {
default: 'Link',
},
global: {
stubs,
},
});
const link = wrapper.container.querySelector('a');
expect(link).not.toHaveAttribute('type');
});
expect(wrapper.html()).toMatchSnapshot();
});
describe('props', () => {
describe('variant', () => {
it.each(['solid', 'subtle', 'ghost', 'outline', 'destructive'] as const)(
'should render %s variant',
(variant) => {
const wrapper = render(N8nButton, {
props: { variant },
slots: { default: 'Button' },
global: { stubs },
});
const button = wrapper.container.querySelector('button');
expect(button?.className).toContain(variant);
},
);
});
describe('size', () => {
it.each(['xsmall', 'small', 'medium'] as const)('should render %s size', (size) => {
const wrapper = render(N8nButton, {
props: { size, variant: 'solid' },
slots: { default: 'Button' },
global: { stubs },
});
const button = wrapper.container.querySelector('button');
expect(button?.className).toContain(size);
});
it('should map size="mini" to xsmall', () => {
const wrapper = render(N8nButton, {
props: { size: 'mini', variant: 'solid' },
slots: { default: 'Button' },
global: { stubs },
});
const button = wrapper.container.querySelector('button');
expect(button?.className).toContain('xsmall');
});
it('should map size="xmini" to xsmall', () => {
const wrapper = render(N8nButton, {
props: { size: 'xmini', variant: 'solid' },
slots: { default: 'Button' },
global: { stubs },
});
const button = wrapper.container.querySelector('button');
expect(button?.className).toContain('xsmall');
});
});
describe('loading', () => {
it('should render loading spinner', () => {
it('should show loading spinner when loading', () => {
const wrapper = render(N8nButton, {
props: { loading: true, variant: 'solid' },
slots: { default: 'Button' },
global: { stubs },
});
const button = wrapper.container.querySelector('button');
expect(button?.className).toContain('loading');
expect(wrapper.container.querySelector('n8n-icon-stub')).toBeInTheDocument();
});
it('should be disabled while loading', async () => {
const handleClick = vi.fn();
const wrapper = render(N8nButton, {
props: { loading: true, variant: 'solid' },
attrs: { onClick: handleClick },
slots: { default: 'Button' },
global: { stubs },
});
const button = wrapper.getByRole('button');
expect(button).toBeDisabled();
expect(button).toHaveAttribute('aria-disabled', 'true');
await userEvent.click(button);
expect(handleClick).not.toHaveBeenCalled();
});
});
describe('disabled', () => {
it('should be disabled when disabled prop is true', () => {
const wrapper = render(N8nButton, {
props: { disabled: true, variant: 'solid' },
slots: { default: 'Button' },
global: { stubs },
});
const button = wrapper.getByRole('button');
expect(button).toBeDisabled();
expect(button).toHaveAttribute('aria-disabled', 'true');
});
it('should apply disabled class', () => {
const wrapper = render(N8nButton, {
props: { disabled: true, variant: 'solid' },
slots: { default: 'Button' },
global: { stubs },
});
const button = wrapper.container.querySelector('button');
expect(button?.className).toContain('disabled');
});
it('should prevent navigation on disabled link button', async () => {
const wrapper = render(N8nButton, {
props: {
loading: true,
},
slots,
global: {
stubs,
href: 'https://example.com',
disabled: true,
variant: 'solid',
},
slots: { default: 'Link' },
global: { stubs },
});
expect(wrapper.html()).toMatchSnapshot();
const link = wrapper.container.querySelector('a')!;
const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
const preventDefaultSpy = vi.spyOn(clickEvent, 'preventDefault');
link.dispatchEvent(clickEvent);
expect(preventDefaultSpy).toHaveBeenCalled();
});
});
describe('iconOnly', () => {
it('should apply iconOnly class for square icon button', () => {
const wrapper = render(N8nButton, {
props: {
iconOnly: true,
variant: 'solid',
},
attrs: {
'aria-label': 'Icon button',
},
slots: { default: '<span>+</span>' },
global: { stubs },
});
const button = wrapper.container.querySelector('button');
expect(button?.className).toContain('iconOnly');
});
});
describe('icon', () => {
it('should render icon button', () => {
it('should render icon button with icon name', () => {
const wrapper = render(N8nButton, {
props: {
icon: 'circle-plus',
},
slots,
slots: { default: 'Button' },
global: {
stubs,
},
@@ -50,37 +234,223 @@ describe('components', () => {
});
});
describe('square', () => {
it('should render square button', () => {
describe('label', () => {
it('should render label prop content', () => {
const wrapper = render(N8nButton, {
props: {
square: true,
label: '48',
label: 'Click me',
},
global: {
stubs,
},
});
expect(wrapper.html()).toMatchSnapshot();
expect(wrapper.getByText('Click me')).toBeInTheDocument();
});
});
describe('type', () => {
it('should render highlight button', () => {
describe('class', () => {
it('should apply custom class', () => {
const wrapper = render(N8nButton, {
props: {
type: 'highlight',
},
slots,
global: {
stubs,
},
props: { class: 'custom-class', variant: 'solid' },
slots: { default: 'Button' },
global: { stubs },
});
const button = wrapper.container.querySelector('button');
expect(button?.className).toContain('highlight');
expect(wrapper.html()).toMatchSnapshot();
expect(button?.className).toContain('custom-class');
});
});
});
describe('slots', () => {
it('should render default slot content', () => {
const wrapper = render(N8nButton, {
props: { variant: 'solid' },
slots: {
default: '<span data-test-id="slot-content">Custom Content</span>',
},
global: { stubs },
});
expect(wrapper.getByTestId('slot-content')).toBeInTheDocument();
expect(wrapper.getByText('Custom Content')).toBeInTheDocument();
});
it('should render complex slot content', () => {
const wrapper = render(N8nButton, {
props: { variant: 'solid' },
slots: {
default: '<span>Icon</span><span>Text</span>',
},
global: { stubs },
});
expect(wrapper.getByText('Icon')).toBeInTheDocument();
expect(wrapper.getByText('Text')).toBeInTheDocument();
});
});
describe('events', () => {
it('should emit click event', async () => {
const handleClick = vi.fn();
const wrapper = render(N8nButton, {
props: { variant: 'solid' },
attrs: { onClick: handleClick },
slots: { default: 'Button' },
global: { stubs },
});
await userEvent.click(wrapper.getByRole('button'));
expect(handleClick).toHaveBeenCalledOnce();
});
it('should not emit click when disabled', async () => {
const handleClick = vi.fn();
const wrapper = render(N8nButton, {
props: {
disabled: true,
variant: 'solid',
},
attrs: { onClick: handleClick },
slots: { default: 'Button' },
global: { stubs },
});
await userEvent.click(wrapper.getByRole('button'));
expect(handleClick).not.toHaveBeenCalled();
});
});
describe('accessibility', () => {
it('should be keyboard accessible', async () => {
const handleClick = vi.fn();
const wrapper = render(N8nButton, {
props: { variant: 'solid' },
attrs: { onClick: handleClick },
slots: { default: 'Button' },
global: { stubs },
});
const button = wrapper.getByRole('button');
button.focus();
expect(button).toHaveFocus();
await userEvent.keyboard('{Enter}');
expect(handleClick).toHaveBeenCalledOnce();
});
it('should be focusable', () => {
const wrapper = render(N8nButton, {
props: { variant: 'solid' },
slots: { default: 'Button' },
global: { stubs },
});
const button = wrapper.getByRole('button');
button.focus();
expect(button).toHaveFocus();
});
it('should warn about missing accessible label for icon-only buttons in dev mode', () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
render(N8nButton, {
props: { iconOnly: true, variant: 'solid' },
slots: { default: '+' },
global: { stubs },
});
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('Icon-only buttons should have an accessible label'),
);
consoleSpy.mockRestore();
});
it('should not warn when icon button has aria-label', () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
render(N8nButton, {
props: {
iconOnly: true,
variant: 'solid',
},
attrs: {
'aria-label': 'Add item',
},
slots: { default: '+' },
global: { stubs },
});
const iconWarningCalls = consoleSpy.mock.calls.filter((call) =>
call[0]?.includes?.('Icon-only buttons should have an accessible label'),
);
expect(iconWarningCalls).toHaveLength(0);
consoleSpy.mockRestore();
});
it('should not warn when icon button has title', () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
render(N8nButton, {
props: {
iconOnly: true,
variant: 'solid',
},
attrs: {
title: 'Add item',
},
slots: { default: '+' },
global: { stubs },
});
const iconWarningCalls = consoleSpy.mock.calls.filter((call) =>
call[0]?.includes?.('Icon-only buttons should have an accessible label'),
);
expect(iconWarningCalls).toHaveLength(0);
consoleSpy.mockRestore();
});
});
describe('button type attribute', () => {
it('should pass through additional attributes', () => {
const wrapper = render(N8nButton, {
props: { variant: 'solid' },
attrs: {
'data-test-id': 'custom-button',
'aria-describedby': 'help-text',
},
slots: { default: 'Button' },
global: { stubs },
});
const button = wrapper.getByRole('button');
expect(button).toHaveAttribute('data-test-id', 'custom-button');
expect(button).toHaveAttribute('aria-describedby', 'help-text');
});
});
describe('link button', () => {
it('should render link with proper security attributes', () => {
const wrapper = render(N8nButton, {
props: { href: 'https://external.com', variant: 'solid' },
slots: { default: 'External Link' },
global: { stubs },
});
const link = wrapper.container.querySelector('a');
expect(link).toHaveAttribute('rel', 'nofollow noopener noreferrer');
});
it('should not have disabled attribute on links (uses aria-disabled)', () => {
const wrapper = render(N8nButton, {
props: {
href: 'https://example.com',
disabled: true,
variant: 'solid',
},
slots: { default: 'Disabled Link' },
global: { stubs },
});
const link = wrapper.container.querySelector('a');
expect(link).toHaveAttribute('aria-disabled', 'true');
});
});
});
});
@@ -3,370 +3,412 @@ import { computed, useAttrs, useCssModule, watchEffect } from 'vue';
import type { IconSize } from '../../types';
import type { ButtonProps } from '../../types/button';
import { cn } from '../../utils/cn';
import N8nIcon from '../N8nIcon';
import N8nSpinner from '../N8nSpinner';
const $style = useCssModule();
const attrs = useAttrs();
defineOptions({ name: 'N8nButton' });
defineOptions({
name: 'N8nButton',
inheritAttrs: false,
});
const props = withDefaults(defineProps<ButtonProps>(), {
label: '',
type: 'primary',
variant: 'solid',
size: 'medium',
loading: false,
disabled: false,
outline: false,
text: false,
block: false,
active: false,
square: false,
element: 'button',
});
watchEffect(() => {
if (props.element === 'a' && !props.href) {
console.error('n8n-button:href is required for link buttons');
}
// Map legacy size values to current ones
const effectiveSize = computed(() => {
if (props.size === 'mini' || props.size === 'xmini') return 'xsmall';
return props.size;
});
// Map legacy variant values to current ones
const effectiveVariant = computed(() => {
if (props.variant === 'highlight') return 'ghost';
if (props.variant === 'highlight-fill') return 'ghost';
return props.variant;
});
const computedIconSize = computed((): IconSize | undefined => {
if (props.iconSize) return props.iconSize;
if (effectiveSize.value === 'xsmall') return 'xsmall';
return effectiveSize.value as IconSize;
});
const componentTag = computed(() => {
if (props.href) return 'a';
return 'button';
});
const buttonType = computed(() => {
if (componentTag.value === 'a') return undefined;
return (attrs.type as string | undefined) ?? 'button';
});
const ariaBusy = computed(() => (props.loading ? 'true' : undefined));
const ariaDisabled = computed(() => (props.disabled ? 'true' : undefined));
const isDisabled = computed(() => props.disabled || props.loading);
const iconSize = computed(
(): IconSize | undefined =>
props.iconSize ?? (props.size === 'xmini' || props.size === 'mini' ? 'xsmall' : props.size),
const classes = computed(() =>
cn(
'button',
$style.button,
$style[effectiveVariant.value],
$style[effectiveSize.value],
props.loading && $style.loading,
props.iconOnly && $style.iconOnly,
props.disabled && $style.disabled,
props.class,
),
);
const classes = computed(() => {
return (
`button ${$style.button} ${$style[props.type]}` +
`${props.size ? ` ${$style[props.size]}` : ''}` +
`${props.outline ? ` ${$style.outline}` : ''}` +
`${props.loading ? ` ${$style.loading}` : ''}` +
`${props.float ? ` ${$style[`float-${props.float}`]}` : ''}` +
`${props.text ? ` ${$style.text}` : ''}` +
`${props.disabled ? ` ${$style.disabled}` : ''}` +
`${props.block ? ` ${$style.block}` : ''}` +
`${props.active ? ` ${$style.active}` : ''}` +
`${props.icon || props.loading ? ` ${$style.withIcon}` : ''}` +
`${props.square ? ` ${$style.square}` : ''}`
);
});
// Accessibility warning for icon-only buttons without accessible labels
if (import.meta.env.DEV) {
watchEffect(() => {
if (props.iconOnly && !attrs['aria-label'] && !attrs.title) {
console.warn(
'[N8nButton] Icon-only buttons should have an accessible label. ' +
'Add aria-label or title attribute.',
);
}
});
}
const handleClick = (event: MouseEvent) => {
if (props.href && isDisabled.value) {
event.preventDefault();
}
};
</script>
<template>
<component
:is="element"
:class="classes"
:disabled="isDisabled"
:aria-disabled="ariaDisabled"
:aria-busy="ariaBusy"
:is="componentTag"
v-bind="attrs"
:type="buttonType"
:href="href"
:rel="href ? 'nofollow noopener noreferrer' : undefined"
:disabled="componentTag === 'button' ? isDisabled || undefined : undefined"
:aria-disabled="isDisabled || undefined"
:aria-busy="loading || undefined"
:tabindex="componentTag === 'a' && isDisabled ? -1 : undefined"
:class="classes"
aria-live="polite"
v-bind="{
...attrs,
...(props.nativeType ? { type: props.nativeType } : {}),
}"
@click="handleClick"
>
<span v-if="loading || icon" :class="$style.icon">
<N8nSpinner v-if="loading" :size="iconSize" />
<N8nIcon v-else-if="icon" :icon="icon" :size="iconSize" />
</span>
<span v-if="label">{{ label }}</span>
<template v-else-if="$slots.default"><slot /></template>
<Transition name="n8n-button-fade">
<div v-if="loading" :class="$style['loading-container']">
<div :class="[$style['loading-spinner'], 'n8n-spinner']">
<N8nIcon icon="loader" :size="computedIconSize" transform-origin="center" />
</div>
</div>
</Transition>
<div :class="$style['button-inner']">
<slot name="icon">
<N8nIcon v-if="icon && !loading" :icon="icon" :size="computedIconSize" />
</slot>
<span v-if="label">{{ label }}</span>
<slot v-else />
</div>
</component>
</template>
<style lang="scss">
@use './Button';
.el-button {
@include Button.n8n-button(true);
--button--padding--vertical: var(--spacing--2xs);
--button--padding--horizontal: var(--spacing--xs);
--button--font-size: var(--font-size--2xs);
+ .el-button {
margin-left: var(--spacing--2xs);
}
&.btn--cancel,
&.el-color-dropdown__link-btn {
@include Button.n8n-button-secondary;
}
}
// Import legacy override classes (n8n-button--success, n8n-button--warning, etc.)
// These are global classes added by the migration codemod for legacy button types
@use './Button.legacy';
</style>
<style lang="scss" module>
@use './Button';
@use '../../css/mixins/utils';
@use '../../css/common/var';
@use '../../css/mixins/focus';
.button {
@include Button.n8n-button;
}
appearance: none;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
user-select: none;
width: fit-content;
display: grid;
font-weight: var(--font-weight--medium);
line-height: 1;
cursor: pointer;
text-decoration: none;
$loading-overlay-background-color: rgba(255, 255, 255, 0);
height: var(--button--height);
padding: var(--button--padding);
border-radius: var(--button--radius);
font-size: var(--button--font-size);
/**
* Colors
*/
.secondary {
@include Button.n8n-button-secondary;
}
.highlight {
@include Button.n8n-button-highlight;
}
.highlightFill {
@include Button.n8n-button-highlight-fill;
}
.tertiary {
@include Button.n8n-button-secondary;
}
.success {
@include Button.n8n-button-success;
}
.warning {
@include Button.n8n-button-warning;
}
.danger {
@include Button.n8n-button-danger;
}
/**
* Sizes
*/
.xmini {
--button--padding--vertical: var(--spacing--4xs);
--button--padding--horizontal: var(--spacing--3xs);
--button--font-size: var(--font-size--3xs);
&.square {
height: 22px;
width: 22px;
}
}
.mini {
--button--padding--vertical: var(--spacing--4xs);
--button--padding--horizontal: var(--spacing--2xs);
--button--font-size: var(--font-size--2xs);
&.square {
height: 22px;
width: 22px;
}
}
.small {
--button--padding--vertical: var(--spacing--3xs);
--button--padding--horizontal: var(--spacing--xs);
--button--font-size: var(--font-size--2xs);
&.square {
height: 26px;
width: 26px;
}
}
.medium {
--button--padding--vertical: var(--spacing--2xs);
--button--padding--horizontal: var(--spacing--xs);
--button--font-size: var(--font-size--2xs);
&.square {
height: 30px;
width: 30px;
}
}
.large {
&.square {
height: 42px;
width: 42px;
}
}
.xlarge {
--button--padding--vertical: var(--spacing--xs);
--button--padding--horizontal: var(--spacing--sm);
--button--font-size: var(--font-size--md);
&.square {
height: 46px;
width: 46px;
}
}
/**
* Modifiers
*/
.outline {
--button--color--background: transparent;
--button--color--background--disabled: transparent;
&.primary {
--button--color--text: var(--color--primary);
--button--color--text--disabled: var(--color--primary--tint-1);
--button--border-color--disabled: var(--color--primary--tint-1);
--button--color--background--disabled: transparent;
}
&.success {
--button--color--text: var(--color--success);
--button--border-color: var(--color--success);
--button--border-color--hover: var(--color--success);
--button--color--background--hover: var(--color--success);
--button--color--background--active: var(--color--success);
--button--color--text--disabled: var(--color--success--tint-1);
--button--border-color--disabled: var(--color--success--tint-1);
--button--color--background--disabled: transparent;
}
&.warning {
--button--color--text: var(--color--warning);
--button--border-color: var(--color--warning);
--button--border-color--hover: var(--color--warning);
--button--color--background--hover: var(--color--warning);
--button--color--background--active: var(--color--warning);
--button--color--text--disabled: var(--color--warning--tint-1);
--button--border-color--disabled: var(--color--warning--tint-1);
--button--color--background--disabled: transparent;
}
&.danger {
--button--color--text: var(--color--danger);
--button--border-color: var(--color--danger);
--button--border-color--hover: var(--color--danger);
--button--color--background--hover: var(--color--danger);
--button--color--background--active: var(--color--danger);
--button--color--text--disabled: var(--color--danger--tint-3);
--button--border-color--disabled: var(--color--danger--tint-3);
--button--color--background--disabled: transparent;
}
}
.text {
--button--color--text: var(--text-button--color--text--secondary);
--button--color--background-hover: transparent;
--button--color--background-active: transparent;
--button--color: light-dark(var(--color--neutral-900), var(--color--neutral-100));
--button--shadow: none;
--button--shadow--hover: none;
--button--shadow--active: none;
--button--border-color: transparent;
--button--color--background: transparent;
--button--border-color--hover: transparent;
--button--color--background--hover: transparent;
--button--border-color--active: transparent;
--button--color--background--active: transparent;
--button--border-color--focus: transparent;
--button--color--background--focus: transparent;
--button--border-color--disabled: transparent;
--button--color--background--disabled: transparent;
&:focus {
outline: 0;
}
background-color: var(--button--color--background);
color: var(--button--color);
box-shadow: var(--button--shadow);
border: 1px solid var(--button--border-color);
&.primary {
--button--color--text: var(--color--primary);
--button--color--text--hover: var(--color--primary--shade-1);
--button--color--text--active: var(--color--primary--shade-1);
--button--color--text--focus: var(--color--primary);
--button--color--text--disabled: var(--color--primary--tint-1);
}
&.success {
--button--color--text: var(--color--success);
--button--color--text--hover: var(--color--success--shade-1);
--button--color--text--active: var(--color--success--shade-1);
--button--color--text--focus: var(--color--success);
--button--color--text--disabled: var(--color--success--tint-1);
}
&.warning {
--button--color--text: var(--color--warning);
--button--color--text--hover: var(--color--warning--shade-1);
--button--color--text--active: var(--color--warning--shade-1);
--button--color--text--focus: var(--color--warning);
--button--color--text--disabled: var(--color--warning--tint-1);
}
&.danger {
--button--color--text: var(--color--danger);
--button--color--text--hover: var(--color--danger--shade-1);
--button--color--text--active: var(--color--danger--shade-1);
--button--color--text--focus: var(--color--danger);
--button--color--text--disabled: var(--color--danger--tint-3);
> * {
grid-area: 1 / 1;
}
&:hover {
text-decoration: underline;
background-color: var(--button--color--background-hover);
box-shadow: var(--button--shadow--hover);
border-color: var(--button--border-color--hover);
}
}
.loading {
position: relative;
pointer-events: none;
&:before {
pointer-events: none;
content: '';
position: absolute;
left: -1px;
top: -1px;
right: -1px;
bottom: -1px;
border-radius: inherit;
&:active {
background-color: var(--button--color--background-active);
box-shadow: var(--button--shadow--active);
border-color: var(--button--border-color--active);
}
}
.disabled {
&,
&:hover,
&:active,
&:focus {
outline: none;
}
&:focus-visible {
@include focus.focus-ring;
}
&.xsmall {
--button--height: 1.5rem;
--button--padding: 0 var(--spacing--2xs);
--button--radius: var(--radius--2xs);
--button--font-size: var(--font-size--2xs);
}
&.small {
--button--height: 1.75rem;
--button--padding: 0 var(--spacing--xs);
--button--radius: var(--radius--2xs);
--button--font-size: var(--font-size--xs);
}
&.medium {
--button--height: 2rem;
--button--padding: 0 var(--spacing--xs);
--button--radius: var(--radius--xs);
--button--font-size: var(--font-size--sm);
}
&.large {
--button--height: 2.25rem;
--button--padding: 0 var(--spacing--sm);
--button--radius: var(--radius--xs);
--button--font-size: var(--font-size--md);
}
&.xlarge {
--button--height: 2.5rem;
--button--padding: 0 var(--spacing--sm);
--button--radius: var(--radius--xs);
--button--font-size: var(--font-size--md);
}
&.solid {
--button--color--background: var(--color--orange-400);
--button--color--background-hover: var(--color--orange-500);
--button--color--background-active: var(--color--orange-600);
--button--color: var(--color--neutral-white);
--button--shadow: 0 1px 3px 0
light-dark(var(--color--black-alpha-100), var(--color--black-alpha-200));
--button--shadow--hover: 0 1px 3px 0
light-dark(var(--color--black-alpha-100), var(--color--black-alpha-200));
--button--shadow--active: 0 1px 3px 0
light-dark(var(--color--black-alpha-100), var(--color--black-alpha-200));
--button--border-color: var(--color--orange-400);
--button--border-color--hover: var(--color--orange-500);
--button--border-color--active: var(--color--orange-600);
}
&.subtle {
--button--color--background: light-dark(var(--color--neutral-white), var(--color--neutral-800));
--button--color--background-hover: light-dark(
var(--color--neutral-150),
var(--color--neutral-700)
);
--button--color--background-active: light-dark(
var(--color--neutral-200),
var(--color--neutral-600)
);
--button--shadow:
0 1px 2px light-dark(var(--color--black-alpha-100), var(--color--black-alpha-300)),
0 0 0 1px light-dark(transparent, var(--color--black-alpha-100));
--button--shadow--hover:
0 1px 3px 0 light-dark(var(--color--black-alpha-200), var(--color--black-alpha-300)),
0 0 0 1px light-dark(transparent, var(--color--black-alpha-100));
--button--shadow--active:
0 1px 3px 0 light-dark(var(--color--black-alpha-200), var(--color--black-alpha-300)),
0 0 0 1px light-dark(transparent, var(--color--black-alpha-100));
--button--border-color: light-dark(
var(--color--black-alpha-200),
var(--color--white-alpha-100)
);
--button--border-color--hover: light-dark(
var(--color--black-alpha-200),
var(--color--white-alpha-300)
);
--button--border-color--active: light-dark(
var(--color--black-alpha-300),
var(--color--white-alpha-300)
);
}
&.outline {
--button--color--background: transparent;
--button--color--background-hover: light-dark(
var(--color--neutral-150),
var(--color--white-alpha-100)
);
--button--color--background-active: light-dark(
var(--color--black-alpha-200),
var(--color--white-alpha-200)
);
--button--border-color: light-dark(
var(--color--black-alpha-200),
var(--color--white-alpha-100)
);
--button--border-color--hover: light-dark(
var(--color--black-alpha-200),
var(--color--white-alpha-200)
);
--button--border-color--active: light-dark(
var(--color--black-alpha-300),
var(--color--white-alpha-300)
);
}
&.ghost {
--button--color--background: transparent;
--button--color--background-hover: light-dark(
var(--color--black-alpha-100),
var(--color--white-alpha-100)
);
--button--color--background-active: light-dark(
var(--color--black-alpha-200),
var(--color--white-alpha-200)
);
--button--border-color: transparent;
}
&.destructive {
--button--color--background: light-dark(var(--color--red-500), var(--color--red-600));
--button--color--background-hover: light-dark(var(--color--red-600), var(--color--red-500));
--button--color--background-active: light-dark(var(--color--red-600), var(--color--red-400));
--button--color: var(--color--neutral-white);
--button--shadow: light-dark(
0 1px 3px 0 var(--color--black-alpha-100),
0 1px 3px 0 var(--color--black-alpha-200)
);
--button--shadow--hover: light-dark(
0 1px 3px 0 var(--color--black-alpha-100),
0 1px 3px 0 var(--color--black-alpha-200)
);
--button--shadow--active: light-dark(
0 1px 3px 0 var(--color--black-alpha-100),
0 1px 3px 0 var(--color--black-alpha-200)
);
--button--border-color: light-dark(var(--color--red-500), var(--color--red-600));
--button--border-color--hover: light-dark(var(--color--red-600), var(--color--red-500));
--button--border-color--active: light-dark(var(--color--red-600), var(--color--red-400));
}
&.disabled {
opacity: 0.5;
cursor: not-allowed;
background-image: none;
}
&.loading {
pointer-events: none;
}
&.iconOnly {
width: var(--button--height);
padding: 0;
}
}
.transparent {
--button--color--background: transparent;
--button--color--background--active: transparent;
.loading-container {
height: auto;
display: flex;
align-items: center;
justify-content: center;
}
.withIcon {
display: inline-flex;
justify-content: center;
.button-inner {
display: flex;
align-items: center;
}
.icon {
display: inline-flex;
justify-content: center;
align-items: center;
gap: var(--spacing--3xs);
white-space: nowrap;
svg {
display: block;
/** NOTE (@heymynameisrob): Covers legacy prop label which wraps in span **/
> span {
white-space: nowrap;
}
}
.block {
width: 100%;
.loading-container + .button-inner {
pointer-events: none;
opacity: 0;
}
.float-left {
float: left;
.loading-spinner {
display: flex;
align-items: center;
justify-content: center;
animation: spin 1s linear infinite;
@media (prefers-reduced-motion: reduce) {
animation: none;
}
}
.float-right {
float: right;
/* TODO: Move to global animations css library */
:global(.n8n-button-fade-enter-active),
:global(.n8n-button-fade-leave-active) {
--easing--ease-out: cubic-bezier(0.215, 0.61, 0.355, 1);
transition:
opacity 0.2s var(--easing--ease-out),
transform 0.2s var(--easing--ease-out);
@media (prefers-reduced-motion: reduce) {
transition: opacity 0.1s;
}
}
:global(.n8n-button-fade-enter-from),
:global(.n8n-button-fade-leave-to) {
opacity: 0;
transform: translateY(4px);
filter: blur(2px);
@media (prefers-reduced-motion: reduce) {
transform: none;
filter: none;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>
@@ -1,23 +1,12 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`components > N8nButton > props > icon > should render icon button 1`] = `"<button class="button button primary medium withIcon" aria-live="polite"><span class="icon"><n8n-icon-stub icon="circle-plus" size="medium" spin="false"></n8n-icon-stub></span>Button</button>"`;
exports[`components > N8nButton > props > loading > should render loading spinner 1`] = `"<button class="button button primary medium loading withIcon" disabled="" aria-busy="true" aria-live="polite"><span class="icon"><n8n-spinner-stub size="medium" type="dots"></n8n-spinner-stub></span>Button</button>"`;
exports[`components > N8nButton > props > square > should render square button 1`] = `
"<button class="button button primary medium square" aria-live="polite">
<!--v-if--><span>48</span>
</button>"
`;
exports[`components > N8nButton > props > type > should render highlight button 1`] = `
"<button class="button button highlight medium" aria-live="polite">
<!--v-if-->Button
</button>"
`;
exports[`components > N8nButton > should render correctly 1`] = `
"<button class="button button primary medium" aria-live="polite">
<!--v-if-->Button
exports[`components > N8nButton > props > icon > should render icon button with icon name 1`] = `
"<button type="button" class="button button solid medium" aria-live="polite">
<transition-stub name="n8n-button-fade" appear="false" persisted="false" css="true">
<!--v-if-->
</transition-stub>
<div class="button-inner">
<n8n-icon-stub icon="circle-plus" size="medium" spin="false"></n8n-icon-stub>Button
</div>
</button>"
`;
@@ -19,7 +19,7 @@ exports[`components > N8nDatatable > should render correctly 1`] = `
<td data-v-c73ff18f="" class=""><span data-v-c73ff18f="">Richard Hendricks</span></td>
<td data-v-c73ff18f="" class=""><span data-v-c73ff18f="">29</span></td>
<td data-v-c73ff18f="" class="">
<n8n-button-stub data-v-c73ff18f="" block="false" element="button" label="" square="false" active="false" disabled="false" loading="false" outline="false" size="medium" text="false" type="primary" column="[object Object]"></n8n-button-stub>
<n8n-button-stub data-v-c73ff18f="" variant="solid" size="medium" loading="false" icononly="false" disabled="false" class="" column="[object Object]"></n8n-button-stub>
</td>
</tr>
<tr data-v-c73ff18f="">
@@ -27,7 +27,7 @@ exports[`components > N8nDatatable > should render correctly 1`] = `
<td data-v-c73ff18f="" class=""><span data-v-c73ff18f="">Bertram Gilfoyle</span></td>
<td data-v-c73ff18f="" class=""><span data-v-c73ff18f="">44</span></td>
<td data-v-c73ff18f="" class="">
<n8n-button-stub data-v-c73ff18f="" block="false" element="button" label="" square="false" active="false" disabled="false" loading="false" outline="false" size="medium" text="false" type="primary" column="[object Object]"></n8n-button-stub>
<n8n-button-stub data-v-c73ff18f="" variant="solid" size="medium" loading="false" icononly="false" disabled="false" class="" column="[object Object]"></n8n-button-stub>
</td>
</tr>
<tr data-v-c73ff18f="">
@@ -35,7 +35,7 @@ exports[`components > N8nDatatable > should render correctly 1`] = `
<td data-v-c73ff18f="" class=""><span data-v-c73ff18f="">Dinesh Chugtai</span></td>
<td data-v-c73ff18f="" class=""><span data-v-c73ff18f="">31</span></td>
<td data-v-c73ff18f="" class="">
<n8n-button-stub data-v-c73ff18f="" block="false" element="button" label="" square="false" active="false" disabled="false" loading="false" outline="false" size="medium" text="false" type="primary" column="[object Object]"></n8n-button-stub>
<n8n-button-stub data-v-c73ff18f="" variant="solid" size="medium" loading="false" icononly="false" disabled="false" class="" column="[object Object]"></n8n-button-stub>
</td>
</tr>
<tr data-v-c73ff18f="">
@@ -43,7 +43,7 @@ exports[`components > N8nDatatable > should render correctly 1`] = `
<td data-v-c73ff18f="" class=""><span data-v-c73ff18f="">Jared Dunn </span></td>
<td data-v-c73ff18f="" class=""><span data-v-c73ff18f="">38</span></td>
<td data-v-c73ff18f="" class="">
<n8n-button-stub data-v-c73ff18f="" block="false" element="button" label="" square="false" active="false" disabled="false" loading="false" outline="false" size="medium" text="false" type="primary" column="[object Object]"></n8n-button-stub>
<n8n-button-stub data-v-c73ff18f="" variant="solid" size="medium" loading="false" icononly="false" disabled="false" class="" column="[object Object]"></n8n-button-stub>
</td>
</tr>
<tr data-v-c73ff18f="">
@@ -51,7 +51,7 @@ exports[`components > N8nDatatable > should render correctly 1`] = `
<td data-v-c73ff18f="" class=""><span data-v-c73ff18f="">Richard Hendricks</span></td>
<td data-v-c73ff18f="" class=""><span data-v-c73ff18f="">29</span></td>
<td data-v-c73ff18f="" class="">
<n8n-button-stub data-v-c73ff18f="" block="false" element="button" label="" square="false" active="false" disabled="false" loading="false" outline="false" size="medium" text="false" type="primary" column="[object Object]"></n8n-button-stub>
<n8n-button-stub data-v-c73ff18f="" variant="solid" size="medium" loading="false" icononly="false" disabled="false" class="" column="[object Object]"></n8n-button-stub>
</td>
</tr>
<tr data-v-c73ff18f="">
@@ -59,7 +59,7 @@ exports[`components > N8nDatatable > should render correctly 1`] = `
<td data-v-c73ff18f="" class=""><span data-v-c73ff18f="">Bertram Gilfoyle</span></td>
<td data-v-c73ff18f="" class=""><span data-v-c73ff18f="">44</span></td>
<td data-v-c73ff18f="" class="">
<n8n-button-stub data-v-c73ff18f="" block="false" element="button" label="" square="false" active="false" disabled="false" loading="false" outline="false" size="medium" text="false" type="primary" column="[object Object]"></n8n-button-stub>
<n8n-button-stub data-v-c73ff18f="" variant="solid" size="medium" loading="false" icononly="false" disabled="false" class="" column="[object Object]"></n8n-button-stub>
</td>
</tr>
<tr data-v-c73ff18f="">
@@ -67,7 +67,7 @@ exports[`components > N8nDatatable > should render correctly 1`] = `
<td data-v-c73ff18f="" class=""><span data-v-c73ff18f="">Dinesh Chugtai</span></td>
<td data-v-c73ff18f="" class=""><span data-v-c73ff18f="">31</span></td>
<td data-v-c73ff18f="" class="">
<n8n-button-stub data-v-c73ff18f="" block="false" element="button" label="" square="false" active="false" disabled="false" loading="false" outline="false" size="medium" text="false" type="primary" column="[object Object]"></n8n-button-stub>
<n8n-button-stub data-v-c73ff18f="" variant="solid" size="medium" loading="false" icononly="false" disabled="false" class="" column="[object Object]"></n8n-button-stub>
</td>
</tr>
<tr data-v-c73ff18f="">
@@ -75,7 +75,7 @@ exports[`components > N8nDatatable > should render correctly 1`] = `
<td data-v-c73ff18f="" class=""><span data-v-c73ff18f="">Jared Dunn </span></td>
<td data-v-c73ff18f="" class=""><span data-v-c73ff18f="">38</span></td>
<td data-v-c73ff18f="" class="">
<n8n-button-stub data-v-c73ff18f="" block="false" element="button" label="" square="false" active="false" disabled="false" loading="false" outline="false" size="medium" text="false" type="primary" column="[object Object]"></n8n-button-stub>
<n8n-button-stub data-v-c73ff18f="" variant="solid" size="medium" loading="false" icononly="false" disabled="false" class="" column="[object Object]"></n8n-button-stub>
</td>
</tr>
<tr data-v-c73ff18f="">
@@ -83,7 +83,7 @@ exports[`components > N8nDatatable > should render correctly 1`] = `
<td data-v-c73ff18f="" class=""><span data-v-c73ff18f="">Richard Hendricks</span></td>
<td data-v-c73ff18f="" class=""><span data-v-c73ff18f="">29</span></td>
<td data-v-c73ff18f="" class="">
<n8n-button-stub data-v-c73ff18f="" block="false" element="button" label="" square="false" active="false" disabled="false" loading="false" outline="false" size="medium" text="false" type="primary" column="[object Object]"></n8n-button-stub>
<n8n-button-stub data-v-c73ff18f="" variant="solid" size="medium" loading="false" icononly="false" disabled="false" class="" column="[object Object]"></n8n-button-stub>
</td>
</tr>
<tr data-v-c73ff18f="">
@@ -91,7 +91,7 @@ exports[`components > N8nDatatable > should render correctly 1`] = `
<td data-v-c73ff18f="" class=""><span data-v-c73ff18f="">Bertram Gilfoyle</span></td>
<td data-v-c73ff18f="" class=""><span data-v-c73ff18f="">44</span></td>
<td data-v-c73ff18f="" class="">
<n8n-button-stub data-v-c73ff18f="" block="false" element="button" label="" square="false" active="false" disabled="false" loading="false" outline="false" size="medium" text="false" type="primary" column="[object Object]"></n8n-button-stub>
<n8n-button-stub data-v-c73ff18f="" variant="solid" size="medium" loading="false" icononly="false" disabled="false" class="" column="[object Object]"></n8n-button-stub>
</td>
</tr>
</tbody>
@@ -42,14 +42,13 @@ const emit = defineEmits<{
<N8nTooltip :disabled="!tooltip" :show-after="TOOLTIP_DELAY_MS">
<template #content>{{ tooltip || label }}</template>
<N8nIconButton
type="secondary"
text
variant="ghost"
size="small"
icon-size="large"
:icon="icon"
:aria-label="label"
:data-test-id="testId"
:class="{ [$style.dangerAction]: danger }"
:class="danger ? $style.dangerAction : undefined"
v-bind="$attrs"
@click.stop="emit('click', $event)"
/>
@@ -3,17 +3,14 @@ import type { IconButtonProps } from '../../types/button';
import N8nButton from '../N8nButton';
defineOptions({ name: 'N8nIconButton' });
withDefaults(defineProps<IconButtonProps>(), {
type: 'primary',
const props = withDefaults(defineProps<IconButtonProps>(), {
size: 'medium',
loading: false,
outline: false,
text: false,
disabled: false,
active: false,
iconOnly: true,
});
</script>
<template>
<N8nButton square v-bind="{ ...$attrs, ...$props }" />
<N8nButton v-bind="{ ...$attrs, ...props }" />
</template>
@@ -185,9 +185,9 @@ async function loadEmojiMetadataMap() {
:class="$style['icon-button']"
:icon="model.value"
:size="buttonSize"
:square="true"
icon-only
:disabled="isReadOnly"
type="tertiary"
variant="subtle"
data-test-id="icon-picker-button"
@click="togglePopup"
/>
@@ -195,8 +195,8 @@ async function loadEmojiMetadataMap() {
v-else-if="model.type === 'emoji'"
:class="$style['emoji-button']"
:size="buttonSize"
:square="true"
type="tertiary"
icon-only
variant="subtle"
data-test-id="icon-picker-button"
:disabled="isReadOnly"
@click="togglePopup"
@@ -40,13 +40,23 @@ exports[`N8nPromptInput > rendering > should render correctly with default props
<button
aria-disabled="true"
aria-live="polite"
class="button button primary small disabled withIcon square sendButton sendButton"
class="button button solid small iconOnly disabled sendButton"
data-test-id="send-message-button"
disabled=""
type="button"
>
<span
class="icon"
<transition-stub
appear="false"
css="true"
name="n8n-button-fade"
persisted="false"
>
<!--v-if-->
</transition-stub>
<div
class="button-inner"
>
<svg
aria-hidden="true"
class="n8n-icon"
@@ -66,10 +76,11 @@ exports[`N8nPromptInput > rendering > should render correctly with default props
stroke-width="2"
/>
</svg>
</span>
</div>
</button>
</div>
</div>
@@ -119,12 +130,22 @@ exports[`N8nPromptInput > rendering > should render streaming state without disa
<button
aria-live="polite"
class="button button primary small withIcon square stopButton stopButton"
class="button button solid small iconOnly stopButton"
data-test-id="send-message-button"
type="button"
>
<span
class="icon"
<transition-stub
appear="false"
css="true"
name="n8n-button-fade"
persisted="false"
>
<!--v-if-->
</transition-stub>
<div
class="button-inner"
>
<svg
aria-hidden="true"
class="n8n-icon"
@@ -145,8 +166,10 @@ exports[`N8nPromptInput > rendering > should render streaming state without disa
width="10"
/>
</svg>
</span>
<!--v-if-->
</div>
</button>
</div>
</div>
@@ -11,7 +11,12 @@ describe('N8nSendStopButton', () => {
it('should render send button by default', () => {
const { container } = renderComponent({
global: {
stubs: ['N8nButton'],
stubs: {
N8nButton: {
inheritAttrs: true,
template: '<button v-bind="$attrs"></button>',
},
},
},
});
@@ -28,7 +33,12 @@ describe('N8nSendStopButton', () => {
streaming: true,
},
global: {
stubs: ['N8nButton'],
stubs: {
N8nButton: {
inheritAttrs: true,
template: '<button v-bind="$attrs"></button>',
},
},
},
});
@@ -47,7 +57,7 @@ describe('N8nSendStopButton', () => {
global: {
stubs: {
N8nButton: {
props: ['size', 'type', 'square', 'disabled', 'icon', 'iconSize'],
props: ['size', 'variant', 'iconOnly', 'disabled', 'icon', 'iconSize'],
template: '<button :class="{sendButton: true}" :data-size="size"></button>',
},
},
@@ -66,7 +76,7 @@ describe('N8nSendStopButton', () => {
global: {
stubs: {
N8nButton: {
props: ['disabled', 'type', 'square', 'icon', 'iconSize', 'size'],
props: ['disabled', 'variant', 'iconOnly', 'icon', 'iconSize', 'size'],
template: '<button :disabled="disabled" :class="{sendButton: true}"></button>',
},
},
@@ -84,7 +94,7 @@ describe('N8nSendStopButton', () => {
global: {
stubs: {
N8nButton: {
props: ['disabled', 'type', 'square', 'icon', 'iconSize', 'size'],
props: ['disabled', 'variant', 'iconOnly', 'icon', 'iconSize', 'size'],
template: '<button @click="$emit(\'click\')" :class="{sendButton: true}"></button>',
emits: ['click'],
},
@@ -107,7 +117,7 @@ describe('N8nSendStopButton', () => {
global: {
stubs: {
N8nButton: {
props: ['type', 'square', 'size', 'icon', 'iconSize'],
props: ['variant', 'iconOnly', 'size', 'icon', 'iconSize'],
template: '<button @click="$emit(\'click\')" :class="{stopButton: true}"></button>',
emits: ['click'],
},
@@ -130,7 +140,7 @@ describe('N8nSendStopButton', () => {
global: {
stubs: {
N8nButton: {
props: ['disabled', 'type', 'square', 'icon', 'iconSize', 'size'],
props: ['disabled', 'variant', 'iconOnly', 'icon', 'iconSize', 'size'],
template:
'<button :disabled="disabled" @click="!disabled && $emit(\'click\')" :class="{sendButton: true}"></button>',
emits: ['click'],
@@ -156,13 +166,13 @@ describe('N8nSendStopButton', () => {
global: {
stubs: {
N8nButton: {
props: ['type', 'size', 'iconSize', 'square', 'icon', 'disabled'],
props: ['variant', 'size', 'iconSize', 'iconOnly', 'icon', 'disabled'],
template: `
<button
:data-type="type"
:data-variant="variant"
:data-size="size"
:data-icon-size="iconSize"
:data-square="square"
:data-icon-only="iconOnly"
:data-icon="icon"
:disabled="disabled"
:class="{sendButton: true}"
@@ -173,10 +183,10 @@ describe('N8nSendStopButton', () => {
});
const button = container.querySelector('button');
expect(button).toHaveAttribute('data-type', 'primary');
expect(button).toHaveAttribute('data-variant', 'solid');
expect(button).toHaveAttribute('data-size', 'medium');
expect(button).toHaveAttribute('data-icon-size', 'large');
expect(button).toHaveAttribute('data-square', 'true');
expect(button).toHaveAttribute('data-icon-only', 'true');
expect(button).toHaveAttribute('data-icon', 'arrow-up');
expect(button).not.toHaveAttribute('disabled');
});
@@ -190,12 +200,12 @@ describe('N8nSendStopButton', () => {
global: {
stubs: {
N8nButton: {
props: ['type', 'size', 'square'],
props: ['variant', 'size', 'iconOnly'],
template: `
<button
:data-type="type"
:data-variant="variant"
:data-size="size"
:data-square="square"
:data-icon-only="iconOnly"
:class="{stopButton: true}"
></button>`,
},
@@ -204,9 +214,9 @@ describe('N8nSendStopButton', () => {
});
const button = container.querySelector('button');
expect(button).toHaveAttribute('data-type', 'primary');
expect(button).toHaveAttribute('data-variant', 'solid');
expect(button).toHaveAttribute('data-size', 'small');
expect(button).toHaveAttribute('data-square', '');
expect(button).toHaveAttribute('data-icon-only', '');
});
});
@@ -216,7 +226,7 @@ describe('N8nSendStopButton', () => {
global: {
stubs: {
N8nButton: {
props: ['size', 'type', 'square', 'icon', 'iconSize', 'disabled'],
props: ['size', 'variant', 'iconOnly', 'icon', 'iconSize', 'disabled'],
template: '<button :data-size="size" :class="{sendButton: true}"></button>',
},
},
@@ -230,7 +240,12 @@ describe('N8nSendStopButton', () => {
it('should default to not streaming', () => {
const { container } = renderComponent({
global: {
stubs: ['N8nButton'],
stubs: {
N8nButton: {
inheritAttrs: true,
template: '<button v-bind="$attrs"></button>',
},
},
},
});
@@ -246,7 +261,7 @@ describe('N8nSendStopButton', () => {
global: {
stubs: {
N8nButton: {
props: ['disabled', 'type', 'square', 'icon', 'iconSize', 'size'],
props: ['disabled', 'variant', 'iconOnly', 'icon', 'iconSize', 'size'],
template:
'<button :disabled="disabled" :data-disabled="disabled" :class="{sendButton: true}"></button>',
},
@@ -32,21 +32,21 @@ function handleStop() {
<template>
<N8nButton
v-if="streaming"
variant="solid"
icon-only
:class="$style.stopButton"
type="primary"
:size="size"
icon="filled-square"
icon-size="small"
square
@click="handleStop"
/>
<N8nButton
v-else
variant="solid"
:class="$style.sendButton"
type="primary"
:size="size"
icon-size="large"
:square="!label"
:icon-only="!label"
:icon="label ? undefined : 'arrow-up'"
:disabled="disabled"
@click="handleSend"
@@ -132,8 +132,8 @@ const handleDragEnd = () => {
<N8nPopover :class="$style.container" width="260px" max-height="300px" scroll-type="auto">
<template #trigger>
<N8nButton
variant="subtle"
icon="sliders-horizontal"
type="secondary"
:icon-size="iconSize"
:size="buttonSize"
>
@@ -3,7 +3,7 @@
exports[`TableHeaderControlsButton > Disabled columns > should render correctly when only disabled columns exist 1`] = `
"<div class="container" width="260px" max-height="300px" scroll-type="auto">
<trigger>
<n8n-button-stub icon="sliders-horizontal" block="false" element="button" label="" square="false" active="false" disabled="false" loading="false" outline="false" size="medium" text="false" type="secondary"></n8n-button-stub>
<n8n-button-stub icon="sliders-horizontal" variant="subtle" size="medium" loading="false" icononly="false" disabled="false" class="false"></n8n-button-stub>
</trigger>
<content>
<div class="contentContainer">
@@ -17,7 +17,7 @@ exports[`TableHeaderControlsButton > Disabled columns > should render correctly
exports[`TableHeaderControlsButton > should render correctly with all columns hidden 1`] = `
"<div class="container" width="260px" max-height="300px" scroll-type="auto">
<trigger>
<n8n-button-stub icon="sliders-horizontal" block="false" element="button" label="" square="false" active="false" disabled="false" loading="false" outline="false" size="medium" text="false" type="secondary"></n8n-button-stub>
<n8n-button-stub icon="sliders-horizontal" variant="subtle" size="medium" loading="false" icononly="false" disabled="false" class="false"></n8n-button-stub>
</trigger>
<content>
<div class="contentContainer">
@@ -53,7 +53,7 @@ exports[`TableHeaderControlsButton > should render correctly with all columns hi
exports[`TableHeaderControlsButton > should render correctly with all columns visible 1`] = `
"<div class="container" width="260px" max-height="300px" scroll-type="auto">
<trigger>
<n8n-button-stub icon="sliders-horizontal" block="false" element="button" label="" square="false" active="false" disabled="false" loading="false" outline="false" size="medium" text="false" type="secondary"></n8n-button-stub>
<n8n-button-stub icon="sliders-horizontal" variant="subtle" size="medium" loading="false" icononly="false" disabled="false" class="false"></n8n-button-stub>
</trigger>
<content>
<div class="contentContainer">
@@ -107,7 +107,7 @@ exports[`TableHeaderControlsButton > should render correctly with all columns vi
exports[`TableHeaderControlsButton > should render correctly with mixed visible and hidden columns 1`] = `
"<div class="container" width="260px" max-height="300px" scroll-type="auto">
<trigger>
<n8n-button-stub icon="sliders-horizontal" block="false" element="button" label="" square="false" active="false" disabled="false" loading="false" outline="false" size="medium" text="false" type="secondary"></n8n-button-stub>
<n8n-button-stub icon="sliders-horizontal" variant="subtle" size="medium" loading="false" icononly="false" disabled="false" class="false"></n8n-button-stub>
</trigger>
<content>
<div class="contentContainer">
@@ -157,7 +157,7 @@ exports[`TableHeaderControlsButton > should render correctly with mixed visible
exports[`TableHeaderControlsButton > should render correctly with no columns 1`] = `
"<div class="container" width="260px" max-height="300px" scroll-type="auto">
<trigger>
<n8n-button-stub icon="sliders-horizontal" block="false" element="button" label="" square="false" active="false" disabled="false" loading="false" outline="false" size="medium" text="false" type="secondary"></n8n-button-stub>
<n8n-button-stub icon="sliders-horizontal" variant="subtle" size="medium" loading="false" icononly="false" disabled="false" class="false"></n8n-button-stub>
</trigger>
<content>
<div class="contentContainer">
@@ -1,48 +1,43 @@
import { type ClassValue } from 'clsx';
import { type IconName } from '@n8n/design-system/components/N8nIcon/icons';
import { type IconSize } from './icon';
import type { TextFloat } from './text';
import type { IconName } from '../components/N8nIcon/icons';
const BUTTON_ELEMENT = ['button', 'a'] as const;
export type ButtonElement = (typeof BUTTON_ELEMENT)[number];
const BUTTON_VARIANT = ['solid', 'subtle', 'ghost', 'outline', 'destructive'] as const;
export type ButtonVariant = (typeof BUTTON_VARIANT)[number];
const BUTTON_TYPE = [
'primary',
'secondary',
'tertiary',
'success',
'warning',
'danger',
'highlight',
'highlightFill',
] as const;
export type ButtonType = (typeof BUTTON_TYPE)[number];
/** @deprecated Use 'ghost' or 'subtle' instead */
export type LegacyButtonVariant = 'highlight' | 'highlight-fill';
const BUTTON_SIZE = ['xmini', 'mini', 'small', 'medium', 'large'] as const;
const BUTTON_SIZE = ['mini', 'xmini', 'small', 'medium', 'large', 'xlarge', 'xsmall'] as const;
export type ButtonSize = (typeof BUTTON_SIZE)[number];
const BUTTON_NATIVE_TYPE = ['submit', 'reset', 'button'] as const;
export type ButtonNativeType = (typeof BUTTON_NATIVE_TYPE)[number];
export interface IconButtonProps {
active?: boolean;
disabled?: boolean;
float?: TextFloat;
icon?: IconName;
loading?: boolean;
outline?: boolean;
export interface ButtonProps {
/** Determines the visual style of the button */
variant?: ButtonVariant | LegacyButtonVariant;
/** Determines the size of the button */
size?: ButtonSize;
/** If passed, the button will be rendered as a link */
href?: string;
/** If true, the button will show a loading spinner */
loading?: boolean;
/** If true, button is fixed square size (for icon-only buttons) */
iconOnly?: boolean;
/** If true, the button will be disabled */
disabled?: boolean;
/** Additional classes to apply to the button (accepts string, object, or array) */
class?: ClassValue;
/** @deprecated Use slot instead */
icon?: IconName;
iconSize?: IconSize;
text?: boolean;
type?: ButtonType;
nativeType?: ButtonNativeType;
/** @deprecated Use slot instead */
label?: string;
}
export interface ButtonProps extends IconButtonProps {
block?: boolean;
element?: ButtonElement;
href?: string;
label?: string;
square?: boolean;
export interface IconButtonProps extends ButtonProps {
/** Icon is required for icon buttons */
icon: IconName;
}
export type IN8nButton = {
@@ -1,221 +0,0 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import type { StoryObj } from '@storybook/vue3-vite';
import Button from './Button.vue';
import N8nIcon from '../../../components/N8nIcon/Icon.vue';
const meta = {
title: 'Components v2/Button',
component: Button,
argTypes: {
variant: {
control: 'select',
options: ['solid', 'subtle', 'outline', 'ghost', 'destructive'],
},
size: {
control: 'select',
options: ['xsmall', 'small', 'medium'],
},
loading: {
control: 'boolean',
},
icon: {
control: 'boolean',
},
disabled: {
control: 'boolean',
},
href: {
control: 'text',
},
default: {
control: 'text',
description: 'Button text content',
},
},
parameters: {
docs: {
source: { type: 'dynamic' },
},
},
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Default = {
render: (args) => ({
components: { Button },
setup() {
return { args };
},
template: `
<div style="display: grid; place-items: center;">
<Button v-bind="args">{{ args.default }}</Button>
</div>
`,
}),
args: {
variant: 'outline',
size: 'medium',
loading: false,
default: 'Button',
},
} satisfies Story;
export const Size = {
render: (args) => ({
components: { Button },
setup() {
return { args };
},
template: `
<div style="display: grid; place-items: center;">
<div style="display: flex; gap: 12px; align-items: center;">
<Button variant="solid" size="xsmall">Button</Button>
<Button variant="solid" size="small">Button</Button>
<Button variant="solid" size="medium">Button</Button>
</div>
</div>
`,
}),
args: {},
} satisfies Story;
export const Variant = {
render: (args) => ({
components: { Button },
setup() {
return { args };
},
template: `
<div style="display: grid; place-items: center;">
<div style="display: flex; gap: 12px; align-items: center;">
<Button variant="solid" size="medium">Solid</Button>
<Button variant="subtle" size="medium">Subtle</Button>
<Button variant="outline" size="medium">Outline</Button>
<Button variant="ghost" size="medium">Ghost</Button>
<Button variant="destructive" size="medium">Destructive</Button>
</div>
</div>
`,
}),
args: {},
} satisfies Story;
export const WithIcons = {
render: (args) => ({
components: { Button, N8nIcon },
setup() {
return { args };
},
template: `
<div style="display: grid; place-items: center;">
<div style="display: flex; gap: 12px; align-items: center;">
<Button variant="solid" size="medium">
<N8nIcon icon="plus" size="medium" />
Button
</Button>
<Button variant="solid" size="medium">
Button
<N8nIcon icon="arrow-right" size="medium" />
</Button>
<Button variant="solid" size="medium">
<N8nIcon icon="plus" size="medium" />
Button
<N8nIcon icon="chevron-down" size="medium" />
</Button>
</div>
</div>
`,
}),
args: {},
} satisfies Story;
export const Loading = {
render: (args) => ({
components: { Button },
setup() {
return { args };
},
template: `
<div style="display: grid; place-items: center;">
<div style="display: flex; gap: 12px; align-items: center;">
<Button variant="solid" size="medium" loading>Button</Button>
<Button variant="subtle" size="medium" loading>Button</Button>
<Button variant="outline" size="medium" loading>Button</Button>
<Button variant="ghost" size="medium" loading>Button</Button>
<Button variant="destructive" size="medium" loading>Button</Button>
</div>
</div>
`,
}),
args: {},
} satisfies Story;
export const Link = {
render: (args) => ({
components: { Button },
setup() {
return { args };
},
template: `
<div style="display: grid; place-items: center;">
<div style="display: flex; gap: 12px; align-items: center;">
<Button variant="solid" size="medium" href="https://n8n.io">Link</Button>
<Button variant="subtle" size="medium" href="https://n8n.io">Link</Button>
<Button variant="outline" size="medium" href="https://n8n.io">Link</Button>
<Button variant="ghost" size="medium" href="https://n8n.io">Link</Button>
<Button variant="destructive" size="medium" href="https://n8n.io">Link</Button>
</div>
</div>
`,
}),
args: {},
} satisfies Story;
export const IconOnly = {
render: (args) => ({
components: { Button, N8nIcon },
setup() {
return { args };
},
template: `
<div style="display: grid; place-items: center;">
<div style="display: flex; gap: 12px; align-items: center;">
<Button variant="solid" size="xsmall" icon aria-label="Add">
<N8nIcon icon="plus" size="xsmall" />
</Button>
<Button variant="solid" size="small" icon aria-label="Add">
<N8nIcon icon="plus" size="small" />
</Button>
<Button variant="solid" size="medium" icon aria-label="Add">
<N8nIcon icon="plus" size="medium" />
</Button>
</div>
</div>
`,
}),
args: {},
} satisfies Story;
export const Disabled = {
render: (args) => ({
components: { Button },
setup() {
return { args };
},
template: `
<div style="display: grid; place-items: center;">
<div style="display: flex; gap: 12px; align-items: center;">
<Button variant="solid" size="medium" disabled>Solid</Button>
<Button variant="subtle" size="medium" disabled>Subtle</Button>
<Button variant="outline" size="medium" disabled>Outline</Button>
<Button variant="ghost" size="medium" disabled>Ghost</Button>
<Button variant="destructive" size="medium" disabled>Destructive</Button>
</div>
</div>
`,
}),
args: {},
} satisfies Story;
@@ -1,424 +0,0 @@
import userEvent from '@testing-library/user-event';
import { render } from '@testing-library/vue';
import Button from './Button.vue';
const stubs = ['N8nIcon'];
describe('v2/components/Button', () => {
describe('rendering', () => {
it('should render correctly with default props', () => {
const wrapper = render(Button, {
slots: {
default: 'Click me',
},
global: {
stubs,
},
});
expect(wrapper.getByRole('button')).toBeInTheDocument();
expect(wrapper.getByText('Click me')).toBeInTheDocument();
});
it('should render as button element by default', () => {
const wrapper = render(Button, {
slots: {
default: 'Button',
},
global: {
stubs,
},
});
expect(wrapper.container.querySelector('button')).toBeInTheDocument();
});
it('should render as anchor element when href is provided', () => {
const wrapper = render(Button, {
props: {
href: 'https://example.com',
},
slots: {
default: 'Link Button',
},
global: {
stubs,
},
});
const link = wrapper.container.querySelector('a');
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute('href', 'https://example.com');
expect(link).toHaveAttribute('rel', 'nofollow noopener noreferrer');
});
it('should have type="button" by default', () => {
const wrapper = render(Button, {
slots: {
default: 'Button',
},
global: {
stubs,
},
});
expect(wrapper.getByRole('button')).toHaveAttribute('type', 'button');
});
it('should not have type attribute when rendered as link', () => {
const wrapper = render(Button, {
props: {
href: 'https://example.com',
},
slots: {
default: 'Link',
},
global: {
stubs,
},
});
const link = wrapper.container.querySelector('a');
expect(link).not.toHaveAttribute('type');
});
});
describe('props', () => {
describe('variant', () => {
it.each(['solid', 'subtle', 'ghost', 'outline', 'destructive'] as const)(
'should render %s variant',
(variant) => {
const wrapper = render(Button, {
props: { variant },
slots: { default: 'Button' },
global: { stubs },
});
const button = wrapper.container.querySelector('button');
expect(button?.className).toContain(variant);
},
);
it('should default to outline variant', () => {
const wrapper = render(Button, {
slots: { default: 'Button' },
global: { stubs },
});
const button = wrapper.container.querySelector('button');
expect(button?.className).toContain('outline');
});
});
describe('size', () => {
it.each(['xsmall', 'small', 'medium'] as const)('should render %s size', (size) => {
const wrapper = render(Button, {
props: { size },
slots: { default: 'Button' },
global: { stubs },
});
const button = wrapper.container.querySelector('button');
expect(button?.className).toContain(size);
});
it('should default to small size', () => {
const wrapper = render(Button, {
slots: { default: 'Button' },
global: { stubs },
});
const button = wrapper.container.querySelector('button');
expect(button?.className).toContain('small');
});
});
describe('loading', () => {
it('should show loading spinner when loading', () => {
const wrapper = render(Button, {
props: { loading: true },
slots: { default: 'Button' },
global: { stubs },
});
const button = wrapper.container.querySelector('button');
expect(button?.className).toContain('loading');
expect(wrapper.container.querySelector('n8n-icon-stub')).toBeInTheDocument();
});
it('should be disabled while loading', async () => {
const handleClick = vi.fn();
const wrapper = render(Button, {
props: { loading: true, onClick: handleClick },
slots: { default: 'Button' },
global: { stubs },
});
const button = wrapper.getByRole('button');
expect(button).toBeDisabled();
expect(button).toHaveAttribute('aria-disabled', 'true');
// Verify clicks are blocked
await userEvent.click(button);
expect(handleClick).not.toHaveBeenCalled();
// Verify keyboard activation is also blocked
button.focus();
await userEvent.keyboard('{Enter}');
expect(handleClick).not.toHaveBeenCalled();
});
it('applies loading CSS class to wrapper element', () => {
const wrapper = render(Button, {
props: { loading: true },
slots: { default: 'Button' },
global: { stubs },
});
const button = wrapper.container.querySelector('button');
expect(button?.className).toContain('loading');
});
});
describe('disabled', () => {
it('should be disabled when disabled prop is true', () => {
const wrapper = render(Button, {
props: { disabled: true },
slots: { default: 'Button' },
global: { stubs },
});
const button = wrapper.getByRole('button');
expect(button).toBeDisabled();
expect(button).toHaveAttribute('aria-disabled', 'true');
});
it('should apply disabled class', () => {
const wrapper = render(Button, {
props: { disabled: true },
slots: { default: 'Button' },
global: { stubs },
});
const button = wrapper.container.querySelector('button');
expect(button?.className).toContain('disabled');
});
it('should prevent navigation on disabled link button', async () => {
const wrapper = render(Button, {
props: {
href: 'https://example.com',
disabled: true,
},
slots: { default: 'Link' },
global: { stubs },
});
const link = wrapper.container.querySelector('a')!;
const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
const preventDefaultSpy = vi.spyOn(clickEvent, 'preventDefault');
link.dispatchEvent(clickEvent);
expect(preventDefaultSpy).toHaveBeenCalled();
});
});
describe('icon', () => {
it('should apply icon class for square icon button', () => {
const wrapper = render(Button, {
props: {
icon: true,
'aria-label': 'Icon button',
},
slots: { default: '<span>+</span>' },
global: { stubs },
});
const button = wrapper.container.querySelector('button');
expect(button?.className).toContain('icon');
});
});
describe('class', () => {
it('should apply custom class', () => {
const wrapper = render(Button, {
props: { class: 'custom-class' },
slots: { default: 'Button' },
global: { stubs },
});
const button = wrapper.container.querySelector('button');
expect(button?.className).toContain('custom-class');
});
});
});
describe('slots', () => {
it('should render default slot content', () => {
const wrapper = render(Button, {
slots: {
default: '<span data-test-id="slot-content">Custom Content</span>',
},
global: { stubs },
});
expect(wrapper.getByTestId('slot-content')).toBeInTheDocument();
expect(wrapper.getByText('Custom Content')).toBeInTheDocument();
});
it('should render complex slot content', () => {
const wrapper = render(Button, {
slots: {
default: '<span>Icon</span><span>Text</span>',
},
global: { stubs },
});
expect(wrapper.getByText('Icon')).toBeInTheDocument();
expect(wrapper.getByText('Text')).toBeInTheDocument();
});
});
describe('events', () => {
it('should emit click event', async () => {
const handleClick = vi.fn();
const wrapper = render(Button, {
props: { onClick: handleClick },
slots: { default: 'Button' },
global: { stubs },
});
await userEvent.click(wrapper.getByRole('button'));
expect(handleClick).toHaveBeenCalledOnce();
});
it('should not emit click when disabled', async () => {
const handleClick = vi.fn();
const wrapper = render(Button, {
props: {
disabled: true,
onClick: handleClick,
},
slots: { default: 'Button' },
global: { stubs },
});
await userEvent.click(wrapper.getByRole('button'));
expect(handleClick).not.toHaveBeenCalled();
});
});
describe('accessibility', () => {
it('should be keyboard accessible', async () => {
const handleClick = vi.fn();
const wrapper = render(Button, {
props: { onClick: handleClick },
slots: { default: 'Button' },
global: { stubs },
});
const button = wrapper.getByRole('button');
button.focus();
expect(button).toHaveFocus();
await userEvent.keyboard('{Enter}');
expect(handleClick).toHaveBeenCalledOnce();
});
it('should be focusable', () => {
const wrapper = render(Button, {
slots: { default: 'Button' },
global: { stubs },
});
const button = wrapper.getByRole('button');
button.focus();
expect(button).toHaveFocus();
});
it('should warn about missing accessible label for icon-only buttons in dev mode', () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
render(Button, {
props: { icon: true },
slots: { default: '+' },
global: { stubs },
});
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('Icon-only buttons should have an accessible label'),
);
consoleSpy.mockRestore();
});
it('should not warn when icon button has aria-label', () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
render(Button, {
props: {
icon: true,
'aria-label': 'Add item',
},
slots: { default: '+' },
global: { stubs },
});
expect(consoleSpy).not.toHaveBeenCalled();
consoleSpy.mockRestore();
});
it('should not warn when icon button has title', () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
render(Button, {
props: {
icon: true,
title: 'Add item',
},
slots: { default: '+' },
global: { stubs },
});
expect(consoleSpy).not.toHaveBeenCalled();
consoleSpy.mockRestore();
});
});
describe('button type attribute', () => {
it('should accept custom type attribute', () => {
const wrapper = render(Button, {
attrs: { type: 'submit' },
slots: { default: 'Submit' },
global: { stubs },
});
expect(wrapper.getByRole('button')).toHaveAttribute('type', 'submit');
});
it('should pass through additional attributes', () => {
const wrapper = render(Button, {
attrs: {
'data-test-id': 'custom-button',
'aria-describedby': 'help-text',
},
slots: { default: 'Button' },
global: { stubs },
});
const button = wrapper.getByRole('button');
expect(button).toHaveAttribute('data-test-id', 'custom-button');
expect(button).toHaveAttribute('aria-describedby', 'help-text');
});
});
describe('link button', () => {
it('should render link with proper security attributes', () => {
const wrapper = render(Button, {
props: { href: 'https://external.com' },
slots: { default: 'External Link' },
global: { stubs },
});
const link = wrapper.container.querySelector('a');
expect(link).toHaveAttribute('rel', 'nofollow noopener noreferrer');
});
it('should not have disabled attribute on links (uses aria-disabled)', () => {
const wrapper = render(Button, {
props: {
href: 'https://example.com',
disabled: true,
},
slots: { default: 'Disabled Link' },
global: { stubs },
});
const link = wrapper.container.querySelector('a');
// Links don't support disabled attribute, but should have aria-disabled
expect(link).toHaveAttribute('aria-disabled', 'true');
});
});
});
@@ -1,365 +0,0 @@
<script lang="ts" setup>
import { computed, useAttrs, useCssModule, onMounted } from 'vue';
import type { ButtonHTMLAttributes } from 'vue';
import { N8nIcon } from '@n8n/design-system/components';
import { cn } from '@n8n/design-system/utils/cn';
interface Props extends /* @vue-ignore */ ButtonHTMLAttributes {
/** Determines the type of button, typically the intent of the action */
variant?: 'solid' | 'subtle' | 'ghost' | 'outline' | 'destructive';
/** Determines the size of the button */
size?: 'xsmall' | 'small' | 'medium';
/** If passed, the button will be rendered as a link */
href?: string;
/** If passed, the button will be rendered as a loading state */
loading?: boolean;
/** If true, forces equal width and height (square button for icons) */
icon?: boolean;
/** If true, the button will be disabled */
disabled?: boolean;
/** Additional classes to apply to the button */
class?: string;
}
defineOptions({
inheritAttrs: false,
});
const props = withDefaults(defineProps<Props>(), {
variant: 'outline',
size: 'small',
href: undefined,
loading: false,
icon: false,
disabled: false,
class: undefined,
});
const $style = useCssModule();
const attrs = useAttrs() as ButtonHTMLAttributes;
onMounted(() => {
if (import.meta.env.DEV && props.icon) {
const hasAccessibleLabel = attrs['aria-label'] || attrs['aria-labelledby'] || attrs.title;
if (!hasAccessibleLabel) {
console.warn(
'[Button] Icon-only buttons should have an accessible label. ' +
'Add aria-label, aria-labelledby, or title attribute.',
);
}
}
});
const classes = computed(() =>
cn(
$style.button,
$style[props.variant],
$style[props.size],
props.loading && $style.loading,
props.icon && $style.icon,
props.disabled && $style.disabled,
props.class,
),
);
const componentTag = computed(() => (props.href ? 'a' : 'button'));
const buttonType = computed(() => {
if (props.href) return undefined;
return (attrs.type as string) ?? 'button';
});
const handleClick = (event: MouseEvent) => {
if (props.href && props.disabled) {
event.preventDefault();
}
};
</script>
<template>
<component
:is="componentTag"
:type="buttonType"
:href="href"
:rel="href ? 'nofollow noopener noreferrer' : undefined"
:disabled="disabled || loading || undefined"
:aria-disabled="disabled || loading || undefined"
:class="classes"
v-bind="attrs"
@click="handleClick"
>
<Transition name="n8n-button-fade">
<div v-if="loading" :class="$style['loading-container']">
<div :class="$style['loading-spinner']">
<N8nIcon icon="loader" size="large" transform-origin="center" />
</div>
</div>
</Transition>
<div :class="$style['button-inner']">
<slot />
</div>
</component>
</template>
<style lang="scss" module>
@use '../../../css/mixins/focus';
.button {
appearance: none;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
user-select: none;
width: fit-content;
display: grid;
border: none;
font-weight: var(--font-weight--medium);
line-height: 1lh;
cursor: pointer;
text-decoration: none;
// Size variables
height: var(--button--height);
padding: var(--button--padding);
border-radius: var(--button--radius);
font-size: var(--button--font-size);
// Variant variables with defaults
--button--color--background: transparent;
--button--color--background-hover: transparent;
--button--color--background-active: transparent;
--button--color: light-dark(var(--color--neutral-900), var(--color--neutral-100));
--button--shadow: none;
--button--shadow--hover: none;
--button--shadow--active: none;
background-color: var(--button--color--background);
color: var(--button--color);
box-shadow: var(--button--shadow);
> * {
grid-area: 1 / 1;
}
&:hover {
background-color: var(--button--color--background-hover);
box-shadow: var(--button--shadow--hover);
}
&:active {
background-color: var(--button--color--background-active);
box-shadow: var(--button--shadow--active);
}
&:focus {
outline: none;
}
&:focus-visible {
@include focus.focus-ring;
}
// Size variants
&.xsmall {
--button--height: 1.5rem;
--button--padding: 0 var(--spacing--2xs);
--button--radius: var(--radius--2xs);
--button--font-size: var(--font-size--2xs);
}
&.small {
--button--height: 1.75rem;
--button--padding: 0 var(--spacing--xs);
--button--radius: var(--radius--2xs);
--button--font-size: var(--font-size--xs);
}
&.medium {
--button--height: 2.25rem;
--button--padding: 0 var(--spacing--xs);
--button--radius: var(--radius--xs);
--button--font-size: var(--font-size--sm);
}
// Style variants
&.solid {
--button--color--background: var(--color--orange-400);
--button--color--background-hover: var(--color--orange-500);
--button--color--background-active: var(--color--orange-600);
--button--color: var(--color--neutral-white);
--button--shadow:
0 1px 3px 0 light-dark(var(--color--black-alpha-100), var(--color--black-alpha-200)),
0 0 0 1px var(--color--orange-400);
--button--shadow--hover:
0 1px 3px 0 light-dark(var(--color--black-alpha-100), var(--color--black-alpha-200)),
0 0 0 1px var(--color--orange-500);
--button--shadow--active:
0 1px 3px 0 light-dark(var(--color--black-alpha-100), var(--color--black-alpha-200)),
0 0 0 1px var(--color--orange-600);
}
&.subtle {
--button--color--background: light-dark(var(--color--neutral-white), var(--color--neutral-800));
--button--color--background-hover: light-dark(
var(--color--neutral-200),
var(--color--neutral-700)
);
--button--color--background-active: light-dark(
var(--color--neutral-250),
var(--color--neutral-600)
);
--button--shadow:
0 1px 3px 0 light-dark(var(--color--black-alpha-200), var(--color--black-alpha-300)),
0 0 0 1px light-dark(var(--color--black-alpha-100), var(--color--white-alpha-100)),
0 0 0 2px light-dark(transparent, var(--color--black-alpha-100));
--button--shadow--hover:
0 1px 3px 0 light-dark(var(--color--black-alpha-200), var(--color--black-alpha-300)),
0 0 0 1px light-dark(var(--color--black-alpha-200), var(--color--white-alpha-300)),
0 0 0 2px light-dark(transparent, var(--color--black-alpha-100));
--button--shadow--active:
0 1px 3px 0 light-dark(var(--color--black-alpha-200), var(--color--black-alpha-300)),
0 0 0 1px light-dark(var(--color--black-alpha-300), var(--color--white-alpha-300)),
0 0 0 2px light-dark(transparent, var(--color--black-alpha-100));
}
&.outline {
--button--color--background: transparent;
--button--color--background-hover: light-dark(
var(--color--black-alpha-200),
var(--color--white-alpha-100)
);
--button--color--background-active: light-dark(
var(--color--black-alpha-300),
var(--color--white-alpha-200)
);
--button--shadow: 0 0 0 1px
light-dark(var(--color--black-alpha-100), var(--color--white-alpha-100));
--button--shadow--hover: 0 0 0 1px
light-dark(var(--color--black-alpha-200), var(--color--white-alpha-200));
--button--shadow--active: 0 0 0 1px
light-dark(var(--color--black-alpha-300), var(--color--white-alpha-300));
}
&.ghost {
--button--color--background: transparent;
--button--color--background-hover: light-dark(
var(--color--black-alpha-200),
var(--color--white-alpha-100)
);
--button--color--background-active: light-dark(
var(--color--black-alpha-300),
var(--color--white-alpha-200)
);
--button--shadow: 0 0 0 0 transparent;
--button--shadow--hover: 0 0 0 1px
light-dark(var(--color--black-alpha-200), var(--color--white-alpha-100));
--button--shadow--active: 0 0 0 1px
light-dark(var(--color--black-alpha-200), var(--color--white-alpha-100));
}
&.destructive {
--button--color--background: light-dark(var(--color--red-500), var(--color--red-600));
--button--color--background-hover: light-dark(var(--color--red-600), var(--color--red-500));
--button--color--background-active: light-dark(var(--color--red-600), var(--color--red-400));
--button--color: var(--color--neutral-white);
--button--shadow:
light-dark(
0 1px 3px 0 var(--color--black-alpha-100),
0 1px 3px 0 var(--color--black-alpha-200)
),
0 0 0 1px light-dark(var(--color--red-500), var(--color--red-600));
--button--shadow--hover:
light-dark(
0 1px 3px 0 var(--color--black-alpha-100),
0 1px 3px 0 var(--color--black-alpha-200)
),
0 0 0 1px light-dark(var(--color--red-600), var(--color--red-500));
--button--shadow--active:
light-dark(
0 1px 3px 0 var(--color--black-alpha-100),
0 1px 3px 0 var(--color--black-alpha-200)
),
0 0 0 1px light-dark(var(--color--red-600), var(--color--red-400));
}
&.link {
cursor: pointer;
}
&.disabled {
opacity: 0.5;
cursor: not-allowed;
}
&.loading {
pointer-events: none;
}
&.icon {
width: var(--button--height);
padding: 0;
}
}
.loading-container {
height: auto;
display: flex;
align-items: center;
justify-content: center;
}
.button-inner {
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing--3xs);
}
.loading-container + .button-inner {
pointer-events: none;
opacity: 0;
}
.loading-spinner {
width: var(--spacing--sm);
height: var(--spacing--sm);
animation: spin 1s linear infinite;
@media (prefers-reduced-motion: reduce) {
animation: none;
}
}
/* TODO: Move to global animations css library */
:global(.n8n-button-fade-enter-active),
:global(.n8n-button-fade-leave-active) {
--easing--ease-out: cubic-bezier(0.215, 0.61, 0.355, 1);
transition:
opacity 0.2s var(--easing--ease-out),
transform 0.2s var(--easing--ease-out);
@media (prefers-reduced-motion: reduce) {
transition: opacity 0.1s;
}
}
:global(.n8n-button-fade-enter-from),
:global(.n8n-button-fade-leave-to) {
opacity: 0;
transform: translateY(4px);
filter: blur(2px);
@media (prefers-reduced-motion: reduce) {
transform: none;
filter: none;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>
@@ -1,190 +0,0 @@
# Component specification
- **Component Name:** N8nButton
## Public API Definition
**Props**
| Prop | Type | Default | Description |
| ---------- | ---------------------------------------------------------------------- | ----------- | -------------------------------------------------------- |
| `variant` | `'solid'` \| `'subtle'` \| `'outline'` \| `'ghost'` \| `'destructive'` | `'outline'` | Visual style of the button |
| `size` | `'xsmall'` \| `'small'` \| `'medium'` | `'small'` | Size of the button |
| `href` | `string` | - | When provided, renders as `<a>` instead of `<button>` |
| `loading` | `boolean` | `false` | Shows spinner and disables interaction |
| `icon` | `boolean` | `false` | Forces equal width and height (square button for icons) |
| `disabled` | `boolean` | `false` | Disables the button |
| `class` | `string` | - | Additional classes to apply to the button |
Extends `ButtonHTMLAttributes` for native button attributes (`type`, etc.)
**Slots**
| Slot | Description |
| --------- | ------------------------------------- |
| `default` | Button content (text, icons, or both) |
Icons are passed as slot content, allowing flexible positioning (leading, trailing, or both).
**Accessibility**
- Sets `aria-disabled="true"` when disabled
- When `href` is provided, adds `target="_blank"` and `rel="nofollow noopener"`
- Icon-only buttons (with `icon` prop) should include `aria-label`, `aria-labelledby`, or `title` attribute (warning shown in dev mode if missing)
---
## Styling via Class Overrides
For layout-specific styling (full-width, custom colors), use the `class` prop with scoped CSS rather than dedicated props. This keeps the API surface minimal.
#### Full Width Button
```vue
<template>
<N8nButton variant="solid" :class="$style.fullWidth"> Save changes </N8nButton>
</template>
<style module lang="scss">
.fullWidth {
width: 100%;
}
</style>
```
#### Square Icon-Only Button
Use the `icon` prop for square icon buttons:
```vue
<template>
<N8nButton variant="ghost" icon aria-label="Add item">
<N8nIcon name="plus" size="small" />
</N8nButton>
</template>
```
---
## Behavior
**Loading state:**
- Displays a spinner with fade transition
- Button content is hidden (opacity: 0)
- Button is non-interactive (`pointer-events: none`)
**Disabled state:**
- Button has reduced opacity (0.5)
- Cursor changes to `not-allowed`
- Sets `disabled` and `aria-disabled` attributes
**Link buttons (`href`):**
- Renders as `<a>` element
- Automatically adds `target="_blank"` and `rel="nofollow noopener"`
**Icon sizing:**
- Icons passed via slots should use the appropriate size to match the button
- The `icon` prop forces the button to be square (width equals height)
---
## Template Usage Examples
#### Basic Button
```vue
<N8nButton variant="solid">Save changes</N8nButton>
```
#### Button with Leading Icon
```vue
<N8nButton variant="solid">
<N8nIcon name="plus" size="xsmall" />
Add item
</N8nButton>
```
#### Button with Trailing Icon
```vue
<N8nButton variant="outline">
Next step
<N8nIcon name="arrow-right" size="xsmall" />
</N8nButton>
```
#### All Variants
```vue
<N8nButton variant="solid">Solid</N8nButton>
<N8nButton variant="subtle">Subtle</N8nButton>
<N8nButton variant="outline">Outline</N8nButton>
<N8nButton variant="ghost">Ghost</N8nButton>
<N8nButton variant="destructive">Delete</N8nButton>
```
#### Size Variants
```vue
<N8nButton variant="solid" size="xsmall">Extra small</N8nButton>
<N8nButton variant="solid" size="small">Small</N8nButton>
<N8nButton variant="solid" size="medium">Medium</N8nButton>
```
#### Loading State
```vue
<N8nButton variant="solid" loading>
Saving...
</N8nButton>
```
#### Disabled State
```vue
<N8nButton variant="solid" disabled>
Cannot click
</N8nButton>
```
#### Icon-Only Button
```vue
<N8nButton variant="ghost" icon aria-label="Add item">
<N8nIcon name="plus" size="small" />
</N8nButton>
```
#### Link Button
```vue
<N8nButton variant="ghost" href="https://n8n.io">
Learn more
<N8nIcon name="external-link" size="xsmall" />
</N8nButton>
```
---
## Migration from Legacy N8nButton
| Legacy | New |
| ------------------ | ------------------------------------------------ |
| `type="primary"` | `variant="solid"` |
| `type="secondary"` | `variant="subtle"` |
| `type="tertiary"` | `variant="ghost"` |
| `type="danger"` | `variant="destructive"` |
| `type="success"` | `variant="solid"` + custom class (see overrides) |
| `type="warning"` | `variant="solid"` + custom class (see overrides) |
| `outline` | `variant="outline"` |
| `text` | `variant="ghost"` |
| `block` | Custom class with `width: 100%` |
| `square` | Use `icon` prop |
| `icon="name"` | `<N8nIcon name="name" />` in slot |
| `label="text"` | Pass text as slot content |
| `element="a"` | Use `href` prop |
@@ -60,13 +60,12 @@ const onClick = () => {
<slot v-if="$slots.button" name="button" />
<N8nButton
variant="outline"
v-else-if="buttonLabel"
:label="buttonLoading && buttonLoadingLabel ? buttonLoadingLabel : buttonLabel"
:title="buttonTitle"
:type="theme"
:loading="buttonLoading"
size="small"
outline
@click.stop="onClick"
/>
</div>
@@ -383,7 +383,7 @@ onMounted(async () => {
<div :class="$style.header">
<div :class="$style.resolverInfo">
<div :class="$style.resolverIcon">
<N8nIconButton icon="database" type="tertiary" size="large" :disabled="true" />
<N8nIconButton variant="subtle" icon="database" size="large" :disabled="true" />
</div>
<div :class="$style.resolverName">
<N8nInlineTextEdit
@@ -399,10 +399,10 @@ onMounted(async () => {
</div>
<div :class="$style.resolverActions">
<N8nIconButton
variant="subtle"
v-if="isEditMode"
:title="i18n.baseText('credentialResolverEdit.delete')"
icon="trash-2"
type="tertiary"
:disabled="isSaving"
:loading="isDeleting"
data-test-id="credential-resolver-delete-button"
@@ -198,7 +198,7 @@ onMounted(async () => {
@click="save"
/>
<N8nButton
type="secondary"
variant="subtle"
:disabled="isSaving"
:label="i18n.baseText('duplicateWorkflowDialog.cancel')"
float="right"
@@ -68,7 +68,7 @@ const onClaimCreditsClicked = async () => {
}}
<template #trailingContent>
<N8nButton
type="tertiary"
variant="subtle"
size="small"
:label="i18n.baseText('freeAi.credits.callout.claim.button.label')"
:loading="claimingCredits"
@@ -60,7 +60,7 @@ const focusInput = async () => {
<template #footer>
<div :class="$style.footer">
<N8nButton
type="primary"
variant="solid"
float="right"
:disabled="!url || !isValid"
data-test-id="confirm-workflow-import-url-button"
@@ -69,7 +69,7 @@ const focusInput = async () => {
{{ i18n.baseText('mainSidebar.prompt.import') }}
</N8nButton>
<N8nButton
type="secondary"
variant="subtle"
float="right"
data-test-id="cancel-workflow-import-url-button"
@click="closeModal"
@@ -518,7 +518,7 @@ defineExpose({
:class="$style.groupButtonLeft"
:loading="autoSaveForPublish"
:disabled="!publishButtonConfig.enabled || shouldDisablePublishButton"
type="secondary"
variant="subtle"
data-test-id="workflow-open-publish-modal-button"
@click="onPublishButtonClick"
>
@@ -552,7 +552,7 @@ defineExpose({
<template #activator>
<N8nIconButton
:class="$style.groupButtonRight"
type="secondary"
variant="subtle"
icon="chevron-down"
data-test-id="version-menu-button"
/>
@@ -265,8 +265,8 @@ async function handlePublish() {
/>
<div :class="$style.actions">
<N8nButton
variant="subtle"
:disabled="publishing"
type="secondary"
:label="i18n.baseText('generic.cancel')"
data-test-id="workflow-publish-cancel-button"
@click="modalBus.emit('close')"
@@ -107,8 +107,9 @@ const {
@select="handleMenuSelect"
>
<N8nIconButton
class="n8n-button--highlight"
variant="ghost"
size="small"
type="highlight"
icon="plus"
icon-size="large"
aria-label="Add new item"
@@ -151,10 +152,10 @@ const {
size="xsmall"
/>
<N8nButton
variant="subtle"
v-else
:size="'mini'"
:class="$style.upgradeButton"
type="tertiary"
@click="handleMenuSelect(item.id)"
>
{{ upgradeLabel }}
@@ -169,8 +170,9 @@ const {
:shortcut="{ keys: ['k'], metaKey: true }"
>
<N8nIconButton
class="n8n-button--highlight"
variant="ghost"
size="small"
type="highlight"
icon="search"
icon-size="large"
aria-label="Open command palette"
@@ -189,8 +191,9 @@ const {
>
<N8nIconButton
id="toggle-sidebar-button"
class="n8n-button--highlight"
variant="ghost"
size="small"
type="highlight"
icon="panel-left"
icon-size="large"
aria-label="Toggle sidebar"
@@ -139,12 +139,11 @@ function pullWorkfolder() {
</div>
</template>
<N8nButton
variant="ghost"
:disabled="!hasPullPermission"
data-test-id="main-sidebar-source-control-pull"
icon="arrow-down"
type="tertiary"
:size="isCollapsed ? 'small' : 'mini'"
text
:square="isCollapsed"
:label="isCollapsed ? '' : i18n.baseText('settings.sourceControl.button.pull')"
@click="pullWorkfolder"
@@ -167,13 +166,12 @@ function pullWorkfolder() {
</div>
</template>
<N8nButton
variant="ghost"
:square="isCollapsed"
:label="isCollapsed ? '' : i18n.baseText('settings.sourceControl.button.push')"
:disabled="sourceControlStore.preferences.branchReadOnly || !hasPushPermission"
data-test-id="main-sidebar-source-control-push"
icon="arrow-up"
type="tertiary"
text
:size="isCollapsed ? 'small' : 'mini'"
@click="pushWorkfolder"
/>
@@ -91,7 +91,7 @@ const onUserActionToggle = (action: string) => {
data-test-id="user-menu"
:class="{ [$style.userActions]: true, [$style.expanded]: fullyExpanded }"
>
<N8nIconButton icon="ellipsis" text square type="tertiary" />
<N8nIconButton variant="ghost" iconOnly icon="ellipsis" square />
</div>
</div>
</template>
@@ -225,7 +225,7 @@ describe('NodeExecuteButton', () => {
workflowsStore.isWorkflowRunning = true;
const { getByRole } = renderComponent();
expect(getByRole('button').querySelector('.n8n-spinner')).toBeVisible();
expect(getByRole('button')).toHaveAttribute('aria-busy', 'true');
});
it('should be disabled if the node is disabled and show tooltip', async () => {
@@ -2,7 +2,7 @@
import { ref, computed } from 'vue';
import { useI18n } from '@n8n/i18n';
import type { ButtonSize, IUpdateInformation } from '@/Interface';
import type { ButtonType } from '@n8n/design-system';
import type { ButtonVariant } from '@n8n/design-system';
import { type IconName } from '@n8n/design-system/components/N8nIcon/icons';
import { N8nButton, N8nTooltip } from '@n8n/design-system';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
@@ -19,7 +19,7 @@ const props = withDefaults(
telemetrySource: string;
disabled?: boolean;
label?: string;
type?: ButtonType;
variant?: ButtonVariant;
size?: ButtonSize;
icon?: IconName;
square?: boolean;
@@ -152,7 +152,7 @@ async function onClick() {
:loading="isExecuting && showLoadingSpinner"
:disabled="disabled || !!disabledHint || (isExecuting && !showLoadingSpinner)"
:label="buttonLabel"
:type="type"
:variant="variant"
:size="size"
:icon="buttonIcon"
:square="square"
@@ -152,9 +152,9 @@ watch(
<div :class="$style.buttons" data-test-id="nps-survey-ratings">
<div v-for="value in 11" :key="value - 1" :class="$style.container">
<N8nButton
type="tertiary"
variant="subtle"
iconOnly
:label="(value - 1).toString()"
square
@click="selectSurveyValue((value - 1).toString())"
/>
</div>
@@ -2,7 +2,7 @@
import KeyboardShortcutTooltip from '@/app/components/KeyboardShortcutTooltip.vue';
import { useI18n } from '@n8n/i18n';
import { computed } from 'vue';
import type { ButtonType } from '@n8n/design-system';
import type { ButtonVariant } from '@n8n/design-system';
import { N8nButton } from '@n8n/design-system';
@@ -18,14 +18,14 @@ const props = withDefaults(
saved: boolean;
isSaving?: boolean;
disabled?: boolean;
type?: ButtonType;
variant?: ButtonVariant;
withShortcut?: boolean;
shortcutTooltip?: string;
savingLabel?: string;
}>(),
{
isSaving: false,
type: 'primary',
variant: 'solid',
withShortcut: false,
disabled: false,
},
@@ -61,7 +61,7 @@ const shortcutTooltipLabel = computed(() => {
:loading="isSaving"
:disabled="disabled"
:class="$style.button"
:type="type"
:variant="variant"
@click="emit('click')"
/>
</KeyboardShortcutTooltip>
@@ -71,7 +71,7 @@ const shortcutTooltipLabel = computed(() => {
:loading="isSaving"
:disabled="disabled"
:class="$style.button"
:type="type"
:variant="variant"
@click="emit('click')"
/>
</template>
@@ -144,7 +144,7 @@ function closeModal() {
<template #footer>
<div :class="$style.footer">
<N8nButton
type="tertiary"
variant="subtle"
:label="i18n.baseText('executionStopManyModal.button.close')"
data-test-id="sme-close-button"
@click="closeModal"
@@ -53,8 +53,7 @@ const i18n = useI18n();
<N8nButton
v-if="versionsStore.infoUrl"
:text="true"
type="primary"
variant="ghost"
size="large"
:class="$style['link']"
:bold="true"
@@ -136,18 +136,18 @@ onMounted(() => {
<template #footer>
<div :class="$style['popover-footer']">
<N8nButton
variant="subtle"
:label="i18n.baseText('generic.cancel')"
:size="'small'"
:disabled="isSaving"
type="tertiary"
data-test-id="workflow-description-cancel-button"
@click="cancel"
/>
<N8nButton
variant="solid"
:label="i18n.baseText('generic.unsavedWork.confirmMessage.confirmButtonText')"
:loading="isSaving"
:disabled="!canSave || isSaving"
type="primary"
data-test-id="workflow-description-save-button"
@click="save"
/>
@@ -90,7 +90,7 @@ onMounted(() => {
<template #footer="{ close }">
<div :class="$style.footer">
<N8nButton
type="secondary"
variant="subtle"
:label="i18n.baseText('generic.cancel')"
float="right"
data-test-id="cancel-button"
@@ -850,9 +850,8 @@ onBeforeUnmount(() => {
</N8nSelect>
<N8nIconButton
v-if="workflowSettings.credentialResolverId"
variant="ghost"
icon="pen"
type="tertiary"
:text="true"
size="small"
:disabled="readOnlyEnv || !workflowPermissions.update"
:title="i18n.baseText('workflowSettings.credentialResolver.edit')"
@@ -344,7 +344,7 @@ watch(
<N8nText v-show="isDirty" color="text-light" size="small" class="mr-xs">
{{ i18n.baseText('workflows.shareModal.changesHint') }}
</N8nText>
<N8nButton v-if="isHomeTeamProject" type="secondary" @click="modalBus.emit('close')">
<N8nButton variant="subtle" v-if="isHomeTeamProject" @click="modalBus.emit('close')">
{{ i18n.baseText('generic.close') }}
</N8nButton>
<N8nButton
@@ -44,14 +44,14 @@ const handleClearSelection = () => {
{{ getSelectedText() }}
</span>
<N8nButton
type="tertiary"
variant="subtle"
data-test-id="delete-selected-button"
:label="i18n.baseText('generic.delete')"
:class="$style.button"
@click="handleDeleteSelected"
/>
<N8nButton
type="tertiary"
variant="subtle"
data-test-id="clear-selection-button"
:label="getClearSelectionText()"
:class="$style.button"
@@ -113,8 +113,8 @@ onBeforeMount(async () => {
<N8nPopover width="304px" :content-class="$style['popover-content']">
<template #trigger>
<N8nButton
variant="subtle"
icon="funnel"
type="tertiary"
size="small"
:active="hasFilters"
:class="{
@@ -122,7 +122,7 @@ const handleBuilderPromptSubmit = async (prompt: string) => {
<div :class="$style.actionButtons">
<ReadyToRunButton type="secondary" size="large" />
<N8nButton
type="secondary"
variant="subtle"
icon="file"
size="large"
data-test-id="start-from-scratch-button"
@@ -58,7 +58,7 @@ const locale = useI18n();
})
}}
</N8nText>
<N8nButton href="/" element="a" type="secondary">
<N8nButton variant="subtle" href="/">
{{ locale.baseText('error.entityNotFound.action') }}
</N8nButton>
</N8nCard>
@@ -1958,7 +1958,7 @@ onBeforeUnmount(() => {
<template v-if="containsChatTriggerNodes">
<CanvasChatButton
v-if="isLogsPanelOpen"
type="tertiary"
variant="ghost"
:label="i18n.baseText('chat.hide')"
:class="$style.chatButton"
@click="logsStore.toggleOpen(false)"
@@ -1969,7 +1969,7 @@ onBeforeUnmount(() => {
:shortcut="{ keys: ['c'] }"
>
<CanvasChatButton
:type="isRunWorkflowButtonVisible ? 'secondary' : 'primary'"
:variant="isRunWorkflowButtonVisible ? 'outline' : 'solid'"
:label="i18n.baseText('chat.open')"
:class="$style.chatButton"
@click="onOpenChat"
@@ -132,7 +132,7 @@ onMounted(async () => {
></N8nNotice>
<div :class="$style['button-group']">
<N8nButton
type="tertiary"
variant="subtle"
:data-test-id="'consent-deny-button'"
:size="'large'"
:loading="loading"
@@ -142,7 +142,7 @@ onMounted(async () => {
{{ i18n.baseText('generic.deny') }}
</N8nButton>
<N8nButton
type="primary"
variant="solid"
:data-test-id="'consent-allow-button'"
:size="'large'"
:loading="loading"
@@ -1807,9 +1807,9 @@ const onNameSubmit = async (name: string) => {
</span>
</template>
<N8nButton
variant="subtle"
size="small"
icon="folder-plus"
type="tertiary"
data-test-id="add-folder-button"
:class="$style['add-folder-button']"
:disabled="!showRegisteredCommunityCTA && (readOnlyEnv || !hasPermissionToCreateFolders)"
@@ -1828,9 +1828,9 @@ const onNameSubmit = async (name: string) => {
<template #trailingContent>
<div :class="$style['callout-trailing-content']">
<N8nButton
variant="subtle"
data-test-id="easy-ai-button"
size="small"
type="secondary"
@click="createAIStarterWorkflows('callout')"
>
{{ i18n.baseText('generic.startNow') }}
@@ -1866,9 +1866,9 @@ const onNameSubmit = async (name: string) => {
<template #trailingContent>
<div :class="$style['callout-trailing-content']">
<N8nButton
variant="subtle"
data-test-id="easy-ai-button"
size="small"
type="secondary"
@click="handleCreateReadyToRunWorkflows('callout')"
>
{{ i18n.baseText('generic.startNow') }}
@@ -132,17 +132,17 @@ function handleFileImport() {
i18n.baseText('emptyStateBuilderPrompt.orStartWith')
}}</span>
<N8nTooltip :content="i18n.baseText('emptyStateBuilderPrompt.fromScratchTooltip')">
<N8nButton type="secondary" size="small" icon="play" @click="onFromScratch">
<N8nButton variant="subtle" size="small" icon="play" @click="onFromScratch">
{{ i18n.baseText('emptyStateBuilderPrompt.fromScratch') }}
</N8nButton>
</N8nTooltip>
<N8nTooltip :content="i18n.baseText('emptyStateBuilderPrompt.templateTooltip')">
<N8nButton type="secondary" size="small" icon="layout-template" @click="onTemplate">
<N8nButton variant="subtle" size="small" icon="layout-template" @click="onTemplate">
{{ i18n.baseText('emptyStateBuilderPrompt.template') }}
</N8nButton>
</N8nTooltip>
<N8nTooltip :content="i18n.baseText('emptyStateBuilderPrompt.importFromFileTooltip')">
<N8nButton type="secondary" size="small" icon="upload" @click="onImportFromFile">
<N8nButton variant="subtle" size="small" icon="upload" @click="onImportFromFile">
{{ i18n.baseText('emptyStateBuilderPrompt.importFromFile') }}
</N8nButton>
</N8nTooltip>
@@ -73,8 +73,8 @@ const handleClick = () => {
</div>
<div :class="$style.actions">
<N8nButton
variant="subtle"
:label="i18n.baseText('experiments.resourceCenter.sandbox.tryItNow')"
type="secondary"
size="small"
@click.stop="handleClick"
/>
@@ -43,9 +43,9 @@ const handleClick = () => {
</div>
<div :class="[$style.actions, 'mt-m']">
<N8nButton
variant="subtle"
:label="i18n.baseText('experiments.resourceCenter.templateCard.useNow')"
type="secondary"
size="mini"
size="xsmall"
@click.stop="handleClick"
/>
</div>
@@ -66,9 +66,9 @@ const handleUseTemplate = async () => {
</div>
<div :class="[$style.actions, 'mt-m']">
<N8nButton
variant="subtle"
:label="locale.baseText('workflows.templateRecoV2.useTemplate')"
type="secondary"
size="mini"
size="xsmall"
@click="handleUseTemplate"
/>
</div>
@@ -31,8 +31,8 @@ function onDismissClick() {
{{ i18n.baseText('aiAssistant.builder.notificationBanner.text') }}
</span>
<N8nButton
type="primary"
size="mini"
variant="solid"
size="xsmall"
data-test-id="notification-banner-notify"
@click="onNotifyClick"
>
@@ -275,7 +275,7 @@ function submitAnswers() {
<div :class="$style.navigation">
<N8nButton
v-if="!isFirstQuestion"
type="secondary"
variant="subtle"
size="small"
:disabled="disabled"
@click="goToPrevious"
@@ -68,7 +68,7 @@ const startNewSession = async () => {
</template>
<template #footer>
<div :class="$style.footer">
<N8nButton :label="i18n.baseText('generic.cancel')" type="secondary" @click="close" />
<N8nButton variant="subtle" :label="i18n.baseText('generic.cancel')" @click="close" />
<N8nButton
:label="i18n.baseText('aiAssistant.newSessionModal.confirm')"
@click="startNewSession"
@@ -101,7 +101,7 @@ watch(
{{ i18n.baseText('chatHub.personalAgents.description') }}
</N8nText>
</div>
<N8nButton icon="plus" type="primary" size="medium" @click="handleCreateAgent">
<N8nButton variant="solid" icon="plus" size="medium" @click="handleCreateAgent">
{{ i18n.baseText('chatHub.agents.button.newAgent') }}
</N8nButton>
</div>
@@ -821,7 +821,7 @@ function onFilesDropped(files: File[]) {
<div v-if="!showWelcomeScreen" :class="$style.promptContainer">
<N8nIconButton
v-if="!arrivedState.bottom && !isNewSession"
type="secondary"
variant="subtle"
icon="arrow-down"
:class="$style.scrollToBottomButton"
:title="i18n.baseText('chatHub.chat.scrollToBottom')"
@@ -280,8 +280,8 @@ function onSelectTools() {
<div :class="$style.header">
<N8nHeading tag="h2" size="large">{{ title }}</N8nHeading>
<N8nButton
variant="subtle"
v-if="isEditMode"
type="secondary"
icon="trash-2"
:disabled="isDeleting"
:loading="isDeleting"
@@ -386,10 +386,10 @@ function onSelectTools() {
</template>
<template #footer>
<div :class="$style.footer">
<N8nButton type="secondary" @click="modalBus.emit('close')">{{
<N8nButton variant="subtle" @click="modalBus.emit('close')">{{
i18n.baseText('chatHub.tools.editor.cancel')
}}</N8nButton>
<N8nButton type="primary" :disabled="!isValid || isSaving" @click="onSave">
<N8nButton variant="solid" :disabled="!isValid || isSaving" @click="onSave">
{{ saveButtonLabel }}
</N8nButton>
</div>
@@ -59,9 +59,9 @@ function handleSelectMenu(action: MenuAction) {
<div :class="$style.actions">
<N8nIconButton
variant="subtle"
v-if="canEdit"
icon="pen"
type="tertiary"
size="medium"
:title="i18n.baseText('chatHub.agent.card.button.edit')"
@click.prevent="emit('edit')"
@@ -75,11 +75,10 @@ function handleSelectMenu(action: MenuAction) {
>
<template #activator>
<N8nIconButton
variant="ghost"
icon="ellipsis-vertical"
type="tertiary"
size="medium"
:title="i18n.baseText('chatHub.agent.card.button.moreOptions')"
text
:class="$style.actionDropdownTrigger"
/>
</template>
@@ -96,25 +96,24 @@ defineExpose({
</div>
<N8nButton
v-if="showArtifactIcon"
type="secondary"
variant="subtle"
size="medium"
icon="notebook-pen"
text
@click="emit('reopenArtifact')"
/>
<N8nButton
variant="subtle"
v-if="selectedModel?.model.provider === 'custom-agent'"
:class="$style.editAgent"
type="secondary"
size="small"
icon="settings"
:label="i18n.baseText('chatHub.chat.header.button.editAgent')"
@click="emit('editCustomAgent', selectedModel.model.agentId)"
/>
<N8nButton
variant="subtle"
v-if="showOpenWorkflow"
:class="$style.editAgent"
type="secondary"
size="small"
icon="settings"
:label="i18n.baseText('chatHub.chat.header.button.openWorkflow')"
@@ -356,18 +356,16 @@ onBeforeMount(() => {
<div :class="$style.editFooter">
<N8nIconButton
v-if="message.type === 'human'"
native-type="button"
type="secondary"
variant="ghost"
icon="paperclip"
text
@click.stop="handleAttachClick"
/>
<div :class="$style.editActions">
<N8nButton type="secondary" size="small" @click="handleCancelEdit">
<N8nButton variant="subtle" size="small" @click="handleCancelEdit">
{{ i18n.baseText('chatHub.message.edit.cancel') }}
</N8nButton>
<N8nButton
type="primary"
variant="solid"
size="small"
:disabled="!editedText.trim() || isEditSubmitting"
:loading="isEditSubmitting"
@@ -74,10 +74,9 @@ function handleReadAloud() {
:show-after="300"
>
<N8nIconButton
variant="ghost"
:icon="isSpeaking ? 'volume-x' : 'volume-2'"
type="tertiary"
size="medium"
text
@click="handleReadAloud"
/>
<template #content>{{
@@ -88,10 +87,9 @@ function handleReadAloud() {
</N8nTooltip>
<N8nTooltip v-if="canEdit" placement="bottom" :show-after="300">
<N8nIconButton
variant="ghost"
icon="pen"
type="tertiary"
size="medium"
text
data-test-id="chat-message-edit"
:disabled="hasSessionStreaming"
@click="handleEdit"
@@ -100,10 +98,9 @@ function handleReadAloud() {
</N8nTooltip>
<N8nTooltip v-if="canRegenerate" placement="bottom" :show-after="300">
<N8nIconButton
variant="ghost"
icon="refresh-cw"
type="tertiary"
size="medium"
text
data-test-id="chat-message-regenerate"
:disabled="hasSessionStreaming"
@click="handleRegenerate"
@@ -111,13 +108,7 @@ function handleReadAloud() {
<template #content>{{ i18n.baseText('chatHub.message.actions.regenerate') }}</template>
</N8nTooltip>
<N8nTooltip v-if="executionUrl" placement="bottom" :show-after="300">
<N8nIconButton
icon="info"
type="tertiary"
size="medium"
text
data-test-id="chat-message-info"
/>
<N8nIconButton variant="ghost" icon="info" size="medium" data-test-id="chat-message-info" />
<template #content>
{{ i18n.baseText('chatHub.message.actions.executionId') }}:
<N8nLink :to="executionUrl" :new-window="true">
@@ -127,10 +118,9 @@ function handleReadAloud() {
</N8nTooltip>
<template v-if="message.alternatives.length > 1">
<N8nIconButton
variant="ghost"
icon="chevron-left"
type="tertiary"
size="medium"
text
:disabled="hasSessionStreaming || currentAlternativeIndex === 0"
data-test-id="chat-message-prev-alternative"
@click="$emit('switchAlternative', message.alternatives[currentAlternativeIndex - 1])"
@@ -139,10 +129,9 @@ function handleReadAloud() {
{{ `${currentAlternativeIndex + 1}/${message.alternatives.length}` }}
</N8nText>
<N8nIconButton
variant="ghost"
icon="chevron-right"
type="tertiary"
size="medium"
text
:disabled="
hasSessionStreaming || currentAlternativeIndex === message.alternatives.length - 1
"
@@ -366,24 +366,21 @@ defineExpose({
placement="top"
>
<N8nIconButton
native-type="button"
type="secondary"
variant="ghost"
:disabled="messagingState !== 'idle' || !canUploadFiles"
icon="paperclip"
icon-size="large"
text
@click.stop="onAttach"
/>
</N8nTooltip>
<N8nIconButton
v-if="speechInput.isSupported"
native-type="button"
variant="outline"
:title="
speechInput.isListening.value
? i18n.baseText('chatHub.chat.prompt.button.stopRecording')
: i18n.baseText('chatHub.chat.prompt.button.voiceInput')
"
type="secondary"
:disabled="messagingState !== 'idle'"
:icon="speechInput.isListening.value ? 'square' : 'mic'"
:class="{ [$style.recording]: speechInput.isListening.value }"
@@ -392,7 +389,7 @@ defineExpose({
/>
<N8nIconButton
v-if="messagingState !== 'receiving'"
native-type="submit"
type="submit"
:disabled="messagingState !== 'idle' || !message.trim()"
:title="i18n.baseText('chatHub.chat.prompt.button.send')"
:loading="messagingState === 'waitingFirstChunk'"
@@ -138,10 +138,10 @@ const onTableAction = (action: string, settings: ChatProviderSettingsDto) => {
<div :class="$style.actions">
<N8nTooltip :content="i18n.baseText('settings.chatHub.providers.table.refresh.tooltip')">
<N8nButton
variant="subtle"
iconOnly
size="small"
type="tertiary"
icon="refresh-cw"
:square="true"
@click="$emit('refresh')"
/>
</N8nTooltip>
@@ -61,9 +61,8 @@ defineSlots<{
>
<template #activator>
<N8nIconButton
variant="ghost"
icon="ellipsis-vertical"
type="tertiary"
text
:class="$style.actionDropdownTrigger"
/>
</template>
@@ -125,7 +125,7 @@ function handleUpgradeClick() {
<div :class="$style.buttonGroup">
<N8nButton
type="primary"
variant="solid"
size="medium"
icon="plus"
data-test-id="welcome-start-new-chat"
@@ -150,7 +150,7 @@ function handleUpgradeClick() {
</I18nT>
</template>
<N8nButton
type="secondary"
variant="subtle"
size="medium"
icon="users"
:disabled="isInviteDisabled"
@@ -25,10 +25,9 @@ async function handleCopy() {
<template>
<N8nTooltip placement="bottom" :show-after="300">
<N8nIconButton
variant="ghost"
:icon="justCopied ? 'check' : 'copy'"
type="tertiary"
size="medium"
text
:class="$style.button"
tabindex="0"
:aria-label="copyTooltip"
@@ -124,10 +124,10 @@ function onCancel() {
</template>
<template #footer>
<div :class="$style.footer">
<N8nButton type="tertiary" @click="onCancel">
<N8nButton variant="subtle" @click="onCancel">
{{ i18n.baseText('chatHub.credentials.selector.cancel') }}
</N8nButton>
<N8nButton type="primary" :disabled="!selectedCredentialId" @click="onConfirm">
<N8nButton variant="solid" :disabled="!selectedCredentialId" @click="onConfirm">
{{ i18n.baseText('chatHub.credentials.selector.confirm') }}
</N8nButton>
</div>
@@ -91,10 +91,10 @@ function onCancel() {
</template>
<template #footer>
<div :class="$style.footer">
<N8nButton type="tertiary" @click="onCancel">
<N8nButton variant="subtle" @click="onCancel">
{{ i18n.baseText('chatHub.models.byIdSelector.cancel') }}
</N8nButton>
<N8nButton type="primary" :disabled="!modelId" @click="onConfirm">
<N8nButton variant="solid" :disabled="!modelId" @click="onConfirm">
{{ i18n.baseText('chatHub.models.byIdSelector.confirm') }}
</N8nButton>
</div>
@@ -196,8 +196,8 @@ defineExpose({
>
<template #trigger>
<N8nButton
variant="ghost"
:class="$style.dropdownButton"
type="secondary"
:text="text"
data-test-id="chat-model-selector"
>
@@ -276,11 +276,11 @@ watch(
/>
<N8nIconButton
v-if="settings.credentialId"
native-type="button"
type="button"
variant="outline"
:title="i18n.baseText('settings.chatHub.providers.modal.edit.credential.clearButton')"
icon="x"
icon-size="large"
type="secondary"
@click="onCredentialDeselect"
/>
</div>
@@ -340,10 +340,10 @@ watch(
<template #footer>
<div :class="$style.footer">
<div :class="$style.footerRight">
<N8nButton type="tertiary" @click="onCancel">
<N8nButton variant="subtle" @click="onCancel">
{{ i18n.baseText('settings.chatHub.providers.modal.edit.cancel') }}
</N8nButton>
<N8nButton type="primary" @click="onConfirm" :disabled="isConfirmDisabled">
<N8nButton variant="solid" @click="onConfirm" :disabled="isConfirmDisabled">
{{ i18n.baseText('settings.chatHub.providers.modal.edit.confirm') }}
</N8nButton>
</div>
@@ -48,7 +48,7 @@ onMounted(async () => {
<template>
<N8nTooltip :content="disabledTooltip" :disabled="!disabledTooltip" placement="top">
<N8nButton
type="secondary"
variant="subtle"
native-type="button"
:class="[$style.toolsButton, { [$style.transparentBg]: transparentBg }]"
:disabled="disabled"
@@ -291,8 +291,8 @@ onMounted(async () => {
"
>
<N8nButton
variant="subtle"
size="medium"
type="secondary"
:disabled="!canCreateCredentials"
@click="onCreateNewCredential(key)"
>
@@ -337,10 +337,10 @@ onMounted(async () => {
}}
</N8nText>
<div :class="$style.footerRight">
<N8nButton type="tertiary" @click="onCancel">{{
<N8nButton variant="subtle" @click="onCancel">{{
i18n.baseText('chatHub.tools.editor.cancel')
}}</N8nButton>
<N8nButton type="primary" :disabled="isMissingCredentials" @click="handleConfirm">{{
<N8nButton variant="solid" :disabled="isMissingCredentials" @click="handleConfirm">{{
i18n.baseText('chatHub.tools.editor.confirm')
}}</N8nButton>
</div>
@@ -116,8 +116,8 @@ function onSeePlans() {
<div :class="$style.actionButton">
<N8nButton
variant="subtle"
size="small"
type="secondary"
@click="navigateToWorkflow('addEvaluationTrigger')"
>
{{ locale.baseText('evaluations.setupWizard.step1.button') }}
@@ -145,8 +145,8 @@ function onSeePlans() {
</ul>
<div :class="$style.actionButton">
<N8nButton
variant="subtle"
size="small"
type="secondary"
@click="navigateToWorkflow('addEvaluationNode')"
>
{{ locale.baseText('evaluations.setupWizard.step2.button') }}
@@ -183,9 +183,9 @@ function onSeePlans() {
</N8nCallout>
<div :class="$style.actionButton">
<N8nButton
variant="subtle"
v-if="!evaluationsQuotaExceeded"
size="small"
type="secondary"
@click="navigateToWorkflow('addEvaluationNode')"
>
{{ locale.baseText('evaluations.setupWizard.step3.button') }}
@@ -194,8 +194,8 @@ function onSeePlans() {
{{ locale.baseText('generic.seePlans') }}
</N8nButton>
<N8nButton
variant="ghost"
size="small"
text
style="color: var(--color--text--tint-1)"
@click="toggleStep(3)"
>
@@ -230,9 +230,9 @@ function onSeePlans() {
>
<div :class="[$style.actionButton, $style.actionButtonInline]">
<N8nButton
variant="subtle"
v-if="evaluationStore.evaluationSetMetricsNodeExist && !evaluationsQuotaExceeded"
size="medium"
type="secondary"
:disabled="
!evaluationStore.evaluationTriggerExists ||
(!evaluationStore.evaluationSetOutputsNodeExist &&
@@ -243,9 +243,9 @@ function onSeePlans() {
{{ locale.baseText('evaluations.setupWizard.step4.button') }}
</N8nButton>
<N8nButton
variant="subtle"
v-else
size="medium"
type="secondary"
:disabled="
!evaluationStore.evaluationTriggerExists ||
(!evaluationStore.evaluationSetOutputsNodeExist &&
@@ -75,22 +75,22 @@ watch(runningTestRun, (run) => {
<div :class="$style.evaluationsView">
<div :class="$style.header">
<N8nButton
variant="subtle"
v-if="runningTestRun"
:disabled="cancellingTestRun"
:class="$style.runOrStopTestButton"
size="small"
data-test-id="stop-test-button"
:label="locale.baseText('evaluation.stopTest')"
type="secondary"
@click="stopTest"
/>
<N8nButton
variant="solid"
v-else
:class="$style.runOrStopTestButton"
size="small"
data-test-id="run-test-button"
:label="locale.baseText('evaluation.runTest')"
type="primary"
@click="runTest"
/>
</div>
@@ -318,8 +318,8 @@ onMounted(async () => {
</div>
<div :class="$style.runsHeaderButtons">
<N8nIconButton
variant="subtle"
:icon="isAllExpanded ? 'chevrons-down-up' : 'chevrons-up-down'"
type="secondary"
size="medium"
@click="toggleAllExpansion"
/>
@@ -244,20 +244,20 @@ onMounted(async () => {
<N8nTabs :model-value="selectedTab" :options="tabs" @update:model-value="onTabSelected" />
<div :class="$style.actions">
<N8nButton
variant="solid"
v-if="showConnectWorkflowsButton"
:label="i18n.baseText('settings.mcp.connectWorkflows')"
data-test-id="mcp-connect-workflows-header-button"
size="small"
type="primary"
@click="openConnectWorkflowsModal"
/>
<N8nTooltip :content="i18n.baseText('settings.mcp.refresh.tooltip')">
<N8nButton
variant="subtle"
iconOnly
data-test-id="mcp-workflows-refresh-button"
size="small"
type="tertiary"
icon="refresh-cw"
:square="true"
@click="onTableRefresh"
/>
</N8nTooltip>
@@ -29,7 +29,7 @@ const i18n = useI18n();
:description="i18n.baseText('settings.mcp.description')"
:button-text="i18n.baseText('settings.mcp.actionBox.button.label')"
:button-disabled="props.disabled || props.loading"
button-variant="primary"
button-variant="solid"
data-test-id="enable-mcp-access-button"
@click:button="emit('turnOnMcp')"
>
@@ -71,10 +71,10 @@ const handleCopy = async (value: string) => {
placement="bottom"
>
<N8nButton
variant="subtle"
iconOnly
v-if="props.allowCopy && isSupported"
type="tertiary"
:icon="copied ? 'check' : 'copy'"
:square="true"
:class="$style['copy-button']"
:disabled="props.valueLoading"
@click="handleCopy(props.value)"
@@ -147,9 +147,9 @@ onMounted(async () => {
:show-after="MCP_TOOLTIP_DELAY"
>
<N8nButton
type="tertiary"
variant="subtle"
iconOnly
icon="refresh-cw"
:square="true"
:disabled="keyRotating"
@click="rotateKey"
/>
@@ -171,9 +171,9 @@ onMounted(async () => {
>
<N8nButton
v-if="isSupported && !loadingApiKey && !keyRotating"
type="tertiary"
variant="subtle"
iconOnly
:icon="copied ? 'check' : 'copy'"
:square="true"
:class="$style['copy-json-button']"
data-test-id="mcp-json-copy-button"
@click="handleConnectionStringCopy"
@@ -87,8 +87,8 @@ watch(
>
<template #trigger>
<N8nButton
variant="subtle"
data-test-id="mcp-connect-popover-trigger-button"
type="tertiary"
:disabled="disabled"
>
{{ i18n.baseText('settings.mcp.connectPopover.triggerLabel') }}
@@ -99,8 +99,8 @@ const onTableAction = (action: string, item: OAuthClientResponseDto) => {
{{ i18n.baseText('settings.mcp.oauth.table.empty.description') }}
</N8nText>
<N8nButton
variant="solid"
data-test-id="mcp-oauth-create-client-button"
variant="primary"
@click="mcpStore.openConnectPopover()"
>
{{ i18n.baseText('settings.mcp.oauth.table.empty.button') }}
@@ -136,8 +136,8 @@ const onConnectClick = () => {
{{ i18n.baseText('settings.mcp.workflows.table.empty.description') }}
</N8nText>
<N8nButton
variant="solid"
data-test-id="mcp-workflow-table-empty-state-button"
type="primary"
:label="i18n.baseText('settings.mcp.connectWorkflows')"
@click="onConnectClick"
/>

Some files were not shown because too many files have changed in this diff Show More