mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-21 12:51:16 +08:00
refactor(editor): Migrate Button v2 to N8nButton (no-changelog) (#24947)
This commit is contained in:
@@ -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);
|
||||
+2
-2
@@ -545,8 +545,8 @@ defineExpose({
|
||||
>
|
||||
<N8nButton
|
||||
v-if="opt.text"
|
||||
type="secondary"
|
||||
size="mini"
|
||||
variant="subtle"
|
||||
size="xsmall"
|
||||
@click="() => onQuickReply(opt)"
|
||||
>
|
||||
{{ opt.text }}
|
||||
|
||||
+8
-18
@@ -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>
|
||||
|
||||
|
||||
+2
-2
@@ -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?.()"
|
||||
|
||||
+6
-8
@@ -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')"
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
+2
-3
@@ -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)"
|
||||
>
|
||||
|
||||
+2
-2
@@ -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-->
|
||||
|
||||
+2
-2
@@ -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"
|
||||
|
||||
+12
-27
@@ -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>
|
||||
|
||||
+2
-4
@@ -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);
|
||||
|
||||
+15
-7
@@ -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"
|
||||
|
||||
+1
-2
@@ -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"
|
||||
|
||||
+4
-4
@@ -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>
|
||||
|
||||
+8
-19
@@ -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>"
|
||||
`;
|
||||
|
||||
+10
-10
@@ -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>
|
||||
|
||||
+2
-3
@@ -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"
|
||||
|
||||
+35
-12
@@ -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>
|
||||
|
||||
+35
-20
@@ -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>',
|
||||
},
|
||||
|
||||
+4
-4
@@ -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"
|
||||
|
||||
+1
-1
@@ -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"
|
||||
>
|
||||
|
||||
+5
-5
@@ -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"
|
||||
|
||||
+2
-2
@@ -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') }}
|
||||
|
||||
+3
-3
@@ -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>
|
||||
|
||||
+1
-1
@@ -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"
|
||||
/>
|
||||
|
||||
+2
-2
@@ -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>
|
||||
|
||||
+2
-2
@@ -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>
|
||||
|
||||
+2
-2
@@ -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"
|
||||
>
|
||||
|
||||
+1
-1
@@ -275,7 +275,7 @@ function submitAnswers() {
|
||||
<div :class="$style.navigation">
|
||||
<N8nButton
|
||||
v-if="!isFirstQuestion"
|
||||
type="secondary"
|
||||
variant="subtle"
|
||||
size="small"
|
||||
:disabled="disabled"
|
||||
@click="goToPrevious"
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
+3
-4
@@ -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"
|
||||
|
||||
+6
-17
@@ -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'"
|
||||
|
||||
+2
-2
@@ -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"
|
||||
|
||||
+2
-2
@@ -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>
|
||||
|
||||
+2
-2
@@ -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"
|
||||
>
|
||||
|
||||
+4
-4
@@ -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"
|
||||
|
||||
+3
-3
@@ -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>
|
||||
|
||||
+6
-6
@@ -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>
|
||||
|
||||
+1
-1
@@ -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')"
|
||||
>
|
||||
|
||||
+2
-2
@@ -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)"
|
||||
|
||||
+4
-4
@@ -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"
|
||||
|
||||
+1
-1
@@ -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') }}
|
||||
|
||||
+1
-1
@@ -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') }}
|
||||
|
||||
+1
-1
@@ -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
Reference in New Issue
Block a user