Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import React, { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText } from '@elastic/eui';
import { HorizontalMinimalStepper, type MinimalStep } from '../horizontal_minimal_stepper';

const meta: Meta<typeof HorizontalMinimalStepper> = {
title: 'Alerting V2/Compose Discover/HorizontalMinimalStepper',
component: HorizontalMinimalStepper,
parameters: {
layout: 'padded',
},
};

export default meta;
type Story = StoryObj<typeof HorizontalMinimalStepper>;

// ---------------------------------------------------------------------------
// Helper to build step arrays from a current index
// ---------------------------------------------------------------------------
const makeSteps = (titles: string[], currentIndex: number): MinimalStep[] =>
titles.map((title, i) => ({
title,
status: i < currentIndex ? 'complete' : i === currentIndex ? 'current' : 'incomplete',
}));

const RULE_STEPS = [
'Alert Condition',
'Recovery Condition',
'Details & Artifacts',
'Notifications',
];
const RULE_STEPS_SHORT = ['Alert Condition', 'Details & Artifacts', 'Notifications'];

// ---------------------------------------------------------------------------
// Interactive story — click through steps to see the animation
// ---------------------------------------------------------------------------
const InteractiveStory = () => {
const [currentStep, setCurrentStep] = useState(0);
const steps = makeSteps(RULE_STEPS, currentStep);

return (
<div style={{ maxWidth: 480, border: '1px solid #eee', borderRadius: 8, padding: 16 }}>
<HorizontalMinimalStepper steps={steps} />
<EuiSpacer size="m" />
<EuiFlexGroup gutterSize="s" responsive={false}>
<EuiFlexItem grow={false}>
<EuiButton
size="s"
disabled={currentStep === 0}
onClick={() => setCurrentStep((s) => Math.max(0, s - 1))}
>
← Back
</EuiButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
size="s"
fill
disabled={currentStep === RULE_STEPS.length - 1}
onClick={() => setCurrentStep((s) => Math.min(RULE_STEPS.length - 1, s + 1))}
>
Next →
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="s" />
<EuiText size="xs" color="subdued">
Click Next/Back to see the dot→pill animation on the indicators.
</EuiText>
</div>
);
};

export const Interactive: Story = {
render: () => <InteractiveStory />,
};

// ---------------------------------------------------------------------------
// All four states shown at once
// ---------------------------------------------------------------------------
export const AllStates: Story = {
render: () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 24, maxWidth: 480 }}>
{RULE_STEPS.map((_, i) => (
<div key={i} style={{ border: '1px solid #eee', borderRadius: 8, padding: 12 }}>
<HorizontalMinimalStepper steps={makeSteps(RULE_STEPS, i)} />
</div>
))}
</div>
),
};

// ---------------------------------------------------------------------------
// Three-step variant (no Recovery Condition — tracking disabled)
// ---------------------------------------------------------------------------
const ThreeStepsStory = () => {
const [currentStep, setCurrentStep] = useState(0);
const steps = makeSteps(RULE_STEPS_SHORT, currentStep);

return (
<div style={{ maxWidth: 480, border: '1px solid #eee', borderRadius: 8, padding: 16 }}>
<HorizontalMinimalStepper steps={steps} />
<EuiSpacer size="m" />
<EuiFlexGroup gutterSize="s" responsive={false}>
<EuiFlexItem grow={false}>
<EuiButton
size="s"
disabled={currentStep === 0}
onClick={() => setCurrentStep((s) => s - 1)}
>
← Back
</EuiButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
size="s"
fill
disabled={currentStep === RULE_STEPS_SHORT.length - 1}
onClick={() => setCurrentStep((s) => s + 1)}
>
Next →
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="s" />
<EuiText size="xs" color="subdued">
Three-step variant shown when &quot;Track active and recovered state&quot; is disabled (no
Recovery Condition step).
</EuiText>
</div>
);
};

export const ThreeSteps: Story = {
render: () => <ThreeStepsStory />,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import { css } from '@emotion/react';
import { euiCanAnimate } from '@elastic/eui';
import type { UseEuiTheme } from '@elastic/eui';
import type { MinimalStepStatus } from './horizontal_minimal_stepper';

const DOT_SIZE = 8;
const BAR_WIDTH = 24;
const SPRING = 'cubic-bezier(0.34, 1.56, 0.64, 1)';

export const useHorizontalMinimalStepperStyles = ({ euiTheme }: UseEuiTheme) => {
const baseIndicator = css`
height: ${DOT_SIZE}px;
flex-shrink: 0;
${euiCanAnimate} {
transition: width 220ms ${SPRING}, border-radius 220ms ${SPRING}, background-color 150ms ease;
}
`;

const indicatorByStatus: Record<MinimalStepStatus, ReturnType<typeof css>> = {
current: css`
${baseIndicator};
width: ${BAR_WIDTH}px;
border-radius: ${DOT_SIZE / 2}px;
background-color: ${euiTheme.colors.primary};
`,
complete: css`
${baseIndicator};
width: ${DOT_SIZE}px;
border-radius: 50%;
background-color: ${euiTheme.colors.primary};
`,
incomplete: css`
${baseIndicator};
width: ${DOT_SIZE}px;
border-radius: 50%;
background-color: ${euiTheme.colors.lightShade};
`,
};

const indicatorRow = css`
display: flex;
align-items: center;
gap: 4px;
`;

return { indicatorByStatus, indicatorRow };
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import React from 'react';
import { ClassNames } from '@emotion/react';
import { useEuiTheme, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui';
import { useHorizontalMinimalStepperStyles } from './horizontal_minimal_stepper.styles';

/** Mirrors the status subset used by EuiStepsHorizontal. */
export type MinimalStepStatus = 'current' | 'complete' | 'incomplete';

export interface MinimalStep {
title: string;
status: MinimalStepStatus;
}

export interface HorizontalMinimalStepperProps {
/** Steps with their current status — same shape as EuiStepsHorizontal steps (subset). */
steps: MinimalStep[];
}

/**
* Minimal horizontal stepper for compact flyout headers.
*
* Renders a row of small indicators (dots + pill for current step), a bold
* current-step title, and a muted N / N counter.
*
* Animation respects `prefers-reduced-motion` automatically via the
* `euiCanAnimate` CSS media query in the styles file.
*
* Layout is intentionally self-contained — place alongside other elements
* using standard EuiFlexGroup/EuiFlexItem outside this component:
*
* <EuiFlexGroup alignItems="center">
* <EuiFlexItem grow>
* <HorizontalMinimalStepper steps={steps} />
* </EuiFlexItem>
* <EuiFlexItem grow={false}>
* <EuiButtonGroup ... isIconOnly />
* </EuiFlexItem>
* </EuiFlexGroup>
*/
export const HorizontalMinimalStepper: React.FC<HorizontalMinimalStepperProps> = ({ steps }) => {
const euiThemeContext = useEuiTheme();
const { indicatorByStatus, indicatorRow } = useHorizontalMinimalStepperStyles(euiThemeContext);

const currentIndex = steps.findIndex((s) => s.status === 'current');
const displayIndex = currentIndex >= 0 ? currentIndex : 0;
const currentTitle = currentIndex >= 0 ? steps[currentIndex].title : '';
const total = steps.length;

return (
<EuiFlexGroup
alignItems="center"
gutterSize="s"
responsive={false}
role="group"
aria-label={`Step ${displayIndex + 1} of ${total}: ${currentTitle}`}
>
{/* Step indicators — decorative, described by the group aria-label.
ClassNames converts SerializedStyles → real CSS class names so we can
use className on plain divs without needing the Emotion JSX transform. */}
<EuiFlexItem grow={false}>
<ClassNames>
{({ css }) => (
<div className={css(indicatorRow)} aria-hidden>
{steps.map((step, i) => (
<div key={i} className={css(indicatorByStatus[step.status])} />
))}
</div>
)}
</ClassNames>
</EuiFlexItem>

{/* Current step title */}
<EuiFlexItem grow={false}>
<EuiText size="s" aria-current="step">
<strong>{currentTitle}</strong>
</EuiText>
</EuiFlexItem>

{/* Spacer */}
<EuiFlexItem grow />

{/* N / N counter */}
<EuiFlexItem grow={false}>
<EuiText size="xs" color="subdued" aria-label={`Step ${displayIndex + 1} of ${total}`}>
{displayIndex + 1} / {total}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
);
};
Loading