feat(editor): Add N8nStatusDot component (#34963)
Some checks are pending
CI: Master (Build, Test, Lint) / Build for Github Cache (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (22.22.3) (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (24.18.0) (push) Waiting to run
CI: Master (Build, Test, Lint) / Lint (push) Waiting to run
CI: Master (Build, Test, Lint) / Performance (push) Waiting to run
CI: Master (Build, Test, Lint) / Notify Slack on failure (push) Blocked by required conditions

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jan 2026-07-28 02:51:58 +02:00 committed by GitHub
parent 8171aa8261
commit f758a4bd05
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 199 additions and 0 deletions

View File

@ -0,0 +1,83 @@
import type { StoryObj } from '@storybook/vue3-vite';
import N8nStatusDot from './StatusDot.vue';
const meta = {
title: 'Core/StatusDot',
component: N8nStatusDot,
argTypes: {
variant: {
control: 'select',
options: ['success', 'warning', 'danger'],
},
pulse: {
control: 'boolean',
},
},
parameters: {
docs: {
description: {
component:
'A small decorative status indicator dot. Pair it with text that carries the meaning; the dot itself is hidden from assistive technology.',
},
},
},
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Default = {
render: (args) => ({
components: { N8nStatusDot },
setup: () => ({ args }),
template: '<N8nStatusDot v-bind="args" />',
}),
args: {
variant: 'success',
pulse: false,
},
} satisfies Story;
export const Variants = {
render: () => ({
components: { N8nStatusDot },
template: `
<div style="display: flex; flex-direction: column; gap: 16px; padding: 40px;">
<div style="display: flex; align-items: center; gap: 8px;">
<N8nStatusDot variant="success" /> Success
</div>
<div style="display: flex; align-items: center; gap: 8px;">
<N8nStatusDot variant="warning" /> Warning
</div>
<div style="display: flex; align-items: center; gap: 8px;">
<N8nStatusDot variant="danger" /> Danger
</div>
</div>
`,
}),
} satisfies Story;
export const Pulsing = {
render: () => ({
components: { N8nStatusDot },
template: `
<div style="display: flex; flex-direction: column; gap: 16px; padding: 40px;">
<div style="display: flex; align-items: center; gap: 8px;">
<N8nStatusDot variant="success" pulse /> Enabled
</div>
<div style="display: flex; align-items: center; gap: 8px;">
<N8nStatusDot variant="danger" /> Disabled
</div>
</div>
`,
}),
parameters: {
docs: {
description: {
story:
'The pulse draws attention to a live or active state. The animation is disabled when the user prefers reduced motion.',
},
},
},
} satisfies Story;

View File

@ -0,0 +1,36 @@
import { render } from '@testing-library/vue';
import N8nStatusDot from './StatusDot.vue';
describe('components/N8nStatusDot', () => {
it('should render success variant without pulse by default', () => {
const wrapper = render(N8nStatusDot);
const dot = wrapper.container.firstElementChild;
expect(dot?.className).toContain('success');
expect(dot?.className).not.toContain('pulse');
expect(wrapper.html()).toMatchSnapshot();
});
it('should be hidden from assistive technology', () => {
const wrapper = render(N8nStatusDot);
expect(wrapper.container.firstElementChild).toHaveAttribute('aria-hidden', 'true');
});
it.each(['success', 'warning', 'danger'] as const)(
'should render %s variant class',
(variant) => {
const wrapper = render(N8nStatusDot, {
props: { variant },
});
expect(wrapper.container.firstElementChild?.className).toContain(variant);
},
);
it('should render pulse class when pulse is enabled', () => {
const wrapper = render(N8nStatusDot, {
props: { pulse: true },
});
expect(wrapper.container.firstElementChild?.className).toContain('pulse');
expect(wrapper.html()).toMatchSnapshot();
});
});

View File

@ -0,0 +1,6 @@
export type StatusDotVariant = 'success' | 'warning' | 'danger';
export interface StatusDotProps {
variant?: StatusDotVariant;
pulse?: boolean;
}

View File

@ -0,0 +1,63 @@
<script lang="ts" setup>
import type { StatusDotProps } from './StatusDot.types';
defineOptions({ name: 'N8nStatusDot' });
withDefaults(defineProps<StatusDotProps>(), {
variant: 'success',
pulse: false,
});
</script>
<template>
<span :class="[$style[variant], { [$style.pulse]: pulse }]" aria-hidden="true" />
</template>
<style lang="scss" module>
// A single per-variant custom property drives both the fill and the pulse ring,
// so the animation automatically picks up the variant color.
.dot {
--status-dot--color: var(--color--success);
display: inline-block;
flex: 0 0 auto;
width: var(--spacing--2xs);
height: var(--spacing--2xs);
border-radius: var(--radius--full);
background-color: var(--status-dot--color);
}
.success {
composes: dot;
}
.warning {
composes: dot;
--status-dot--color: var(--color--warning);
}
.danger {
composes: dot;
--status-dot--color: var(--color--danger);
}
// Expanding and fading box-shadow ring, tinted with the dot's own color.
.pulse {
animation: statusDotPulse var(--duration--slowest) var(--easing--ease-out) infinite;
@media (prefers-reduced-motion: reduce) {
animation: none;
}
}
@keyframes statusDotPulse {
0% {
box-shadow: 0 0 0 0 color-mix(in srgb, var(--status-dot--color) 55%, transparent);
}
70% {
box-shadow: 0 0 0 var(--spacing--3xs) transparent;
}
100% {
box-shadow: 0 0 0 0 transparent;
}
}
</style>

View File

@ -0,0 +1,5 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`components/N8nStatusDot > should render pulse class when pulse is enabled 1`] = `"<span class="success pulse" aria-hidden="true"></span>"`;
exports[`components/N8nStatusDot > should render success variant without pulse by default 1`] = `"<span class="success" aria-hidden="true"></span>"`;

View File

@ -0,0 +1,5 @@
import N8nStatusDot from './StatusDot.vue';
export default N8nStatusDot;
export type * from './StatusDot.types';

View File

@ -109,6 +109,7 @@ export { default as N8nRecycleScroller } from './N8nRecycleScroller';
export { default as N8nResizeWrapper } from './N8nResizeWrapper';
export { default as N8nSelect } from './N8nSelect';
export { default as N8nSpinner } from './N8nSpinner';
export { default as N8nStatusDot } from './N8nStatusDot';
export { default as N8nSticky } from './N8nSticky';
export { default as N8nResizeableSticky } from './N8nResizeableSticky';
export { default as N8nSuggestedActions } from './N8nSuggestedActions';