diff --git a/.changeset/nice-falcons-worry.md b/.changeset/nice-falcons-worry.md new file mode 100644 index 000000000..8ee75b60e --- /dev/null +++ b/.changeset/nice-falcons-worry.md @@ -0,0 +1,7 @@ +--- +'@drivenets/design-system': minor +'@drivenets/eslint-plugin-design-system': patch +--- + +Add `DsStatusBadgeV2` component. +Deprecate `DsStatusBadge` component. diff --git a/cspell/local-words.txt b/cspell/local-words.txt index b371fb462..d9486843a 100644 --- a/cspell/local-words.txt +++ b/cspell/local-words.txt @@ -27,3 +27,7 @@ TSESTree unhover unitless unrs +Fira +scrollers +affordances +timelapse diff --git a/packages/design-system/src/components/ds-status-badge-v2/ds-status-badge-v2.module.scss b/packages/design-system/src/components/ds-status-badge-v2/ds-status-badge-v2.module.scss new file mode 100644 index 000000000..bace76329 --- /dev/null +++ b/packages/design-system/src/components/ds-status-badge-v2/ds-status-badge-v2.module.scss @@ -0,0 +1,109 @@ +@mixin phase($bg, $icon-color, $font-color: var(--font-main)) { + background-color: $bg; + color: $font-color; + --_icon-color: #{$icon-color}; +} + +$size-medium: 22px; +$size-small: 18px; +$badge-icon-size-small: 12px; + +.root { + display: inline-flex; + align-items: center; + border-radius: 9999px; + width: max-content; +} + +.icon { + display: inline-flex; + color: var(--_icon-color); + pointer-events: none; +} + +.label::first-letter { + text-transform: capitalize; +} + +.medium { + height: $size-medium; + padding: 0 var(--xs) 0 var(--3xs); + gap: var(--3xs); +} + +.small { + height: $size-small; + padding: 0 var(--2xs) 0 var(--3xs); + gap: var(--3xs); +} + +.small .icon { + --_badge-icon-size: #{$badge-icon-size-small}; +} + +.iconOnly { + padding: 0; + justify-content: center; + + &.medium { + width: $size-medium; + } + + &.small { + width: $size-small; + } +} + +.textOnly { + &.medium { + padding: 0 var(--xs); + } + + &.small { + padding: 0 var(--2xs); + } +} + +.not-started { + @include phase(var(--background-secondary), var(--icon-secondary), var(--font-secondary)); +} + +.temporary { + @include phase(var(--background-secondary), var(--icon-secondary), var(--font-secondary)); +} + +.in-review { + @include phase(var(--background-secondary), var(--icon-secondary), var(--font-secondary)); +} + +.pending { + @include phase(var(--background-pending), var(--icon-pending)); +} + +.active { + @include phase(var(--background-active-status), var(--icon-action)); +} + +.execution { + @include phase(var(--background-execution), var(--icon-execution)); +} + +.result-succeeded { + @include phase(var(--background-success), var(--icon-success)); +} + +.result-warning { + @include phase(var(--background-warning), var(--icon-warning)); +} + +.result-failed { + @include phase(var(--background-error-secondary-hover), var(--icon-error)); +} + +.deprecated { + @include phase(var(--background-secondary), var(--icon-secondary), var(--font-secondary)); +} + +.secondary { + background-color: transparent; +} diff --git a/packages/design-system/src/components/ds-status-badge-v2/ds-status-badge-v2.stories.module.scss b/packages/design-system/src/components/ds-status-badge-v2/ds-status-badge-v2.stories.module.scss new file mode 100644 index 000000000..9b4c7f540 --- /dev/null +++ b/packages/design-system/src/components/ds-status-badge-v2/ds-status-badge-v2.stories.module.scss @@ -0,0 +1,29 @@ +.sectionTitle { + font-weight: 600; + font-size: 14px; + color: var(--font-main); +} + +.docsTable { + border-collapse: collapse; + font-size: 13px; + color: var(--font-main); + + th, + td { + padding: var(--xs) var(--standard); + text-align: left; + border-bottom: 1px solid var(--border-main); + } + + th { + font-weight: 600; + } + + code { + font-size: 12px; + padding: 2px 6px; + border-radius: 4px; + background: var(--background-secondary); + } +} diff --git a/packages/design-system/src/components/ds-status-badge-v2/ds-status-badge-v2.stories.tsx b/packages/design-system/src/components/ds-status-badge-v2/ds-status-badge-v2.stories.tsx new file mode 100644 index 000000000..55acf36e3 --- /dev/null +++ b/packages/design-system/src/components/ds-status-badge-v2/ds-status-badge-v2.stories.tsx @@ -0,0 +1,455 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import DsStatusBadgeV2 from './ds-status-badge-v2'; +import { DsStatusBadgeV2 as DsStatusBadgeV2Responsive } from './index'; +import { + type StatusBadgeV2Phase, + statusBadgeV2Phases, + statusBadgeV2Sizes, + statusBadgeV2Variants, +} from './ds-status-badge-v2.types'; +import { phaseIconMap } from './phase-config'; +import { DsIcon } from '../ds-icon'; +import { DsStack } from '../ds-stack'; +import styles from './ds-status-badge-v2.stories.module.scss'; + +const phaseLabels: Record = { + 'not-started': 'Vacant', + temporary: 'Draft', + 'in-review': 'In Review', + pending: 'Reserved', + active: 'Active', + execution: 'Testing', + 'result-succeeded': 'Provisioned', + 'result-warning': 'Warning', + 'result-failed': 'Failed', + deprecated: 'Decommissioned', +}; + +const meta: Meta = { + title: 'Components/StatusBadgeV2', + component: DsStatusBadgeV2, + parameters: { + layout: 'centered', + }, + argTypes: { + phase: { + control: 'select', + options: statusBadgeV2Phases, + description: 'Lifecycle phase determining color and default icon', + }, + label: { + control: 'text', + description: 'Domain-specific text label (always required)', + }, + icon: { + control: 'text', + description: 'Icon override; pass null for text-only', + }, + iconOnly: { + control: 'boolean', + description: 'Hide label text, show as tooltip instead', + }, + variant: { + control: 'select', + options: statusBadgeV2Variants, + description: 'Visual variant: primary (tinted bg) or secondary (transparent)', + }, + size: { + control: 'select', + options: statusBadgeV2Sizes, + description: 'Badge size', + }, + className: { table: { disable: true } }, + style: { table: { disable: true } }, + ref: { table: { disable: true } }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + phase: 'active', + label: 'Active', + }, +}; + +export const AllPhases: Story = { + render: () => ( + + +
Primary — Medium
+ + + + + + + + + + + + +
+ + +
Primary — Small
+ + + + + + + + + + + + +
+ + +
Secondary — Medium
+ + + + + + + + + + + + +
+ + +
Secondary — Small
+ + + + + + + + + + + + +
+
+ ), +}; + +export const IconOnly: Story = { + render: () => ( + + +
Icon-only (hover for tooltip)
+ + + + + + + + + + + + +
+ + +
Icon-only — Small
+ + + + + + + + + + + + +
+ + +
Icon-only Secondary
+ + + + + + + + + + + + +
+
+ ), +}; + +export const TextOnly: Story = { + render: () => ( + +
Text-only (icon=null)
+ + + + + + + + + + + + +
+ ), +}; + +const phaseExamples: Record = { + 'not-started': 'Vacant, Spare', + temporary: 'Design, Draft', + 'in-review': 'PnR, L1 design complete', + pending: 'Reserved, Pending, Ordered', + active: 'Active, Installed, In-use', + execution: 'Testing, Upgrading, Initializing', + 'result-succeeded': 'Test passed, Provisioning complete', + 'result-warning': 'Domain-specific warnings', + 'result-failed': 'Cancelled, Fault, Failed, Disconnected', + deprecated: 'Decommissioned, Sacrificed', +}; + +export const PhaseReference: Story = { + render: () => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PhaseBadgeIconExample Domain Statuses
+ not-started + + + + + {phaseExamples['not-started']}
+ temporary + + + + + {phaseExamples.temporary}
+ in-review + + + + + {phaseExamples['in-review']}
+ pending + + + + + {phaseExamples.pending}
+ active + + + + + {phaseExamples.active}
+ execution + + + + + {phaseExamples.execution}
+ result-succeeded + + + + + {phaseExamples['result-succeeded']}
+ result-warning + + + + + {phaseExamples['result-warning']}
+ result-failed + + + + + {phaseExamples['result-failed']}
+ deprecated + + + + + {phaseExamples.deprecated}
+
+ ), +}; + +export const ResponsiveSize: Story = { + parameters: { layout: 'centered' }, + render: () => ( + + + + + + ), +}; diff --git a/packages/design-system/src/components/ds-status-badge-v2/ds-status-badge-v2.tsx b/packages/design-system/src/components/ds-status-badge-v2/ds-status-badge-v2.tsx new file mode 100644 index 000000000..39989f8b0 --- /dev/null +++ b/packages/design-system/src/components/ds-status-badge-v2/ds-status-badge-v2.tsx @@ -0,0 +1,77 @@ +import type { Ref } from 'react'; +import classNames from 'classnames'; +import styles from './ds-status-badge-v2.module.scss'; +import type { DsStatusBadgeV2BaseProps } from './ds-status-badge-v2.types'; +import { phaseIconMap } from './phase-config'; +import { DsIcon } from '../ds-icon'; +import { DsTooltip } from '../ds-tooltip'; +import { DsTypography } from '../ds-typography'; + +const DsStatusBadgeV2 = ({ + phase, + label, + icon, + iconOnly = false, + variant = 'primary', + size = 'medium', + className, + style, + ref, + 'aria-label': ariaLabel, +}: DsStatusBadgeV2BaseProps) => { + const resolvedIcon = icon === null ? null : (icon ?? phaseIconMap[phase]); + const hasIcon = resolvedIcon !== null; + const isTextOnly = !hasIcon; + const tooltipContent = iconOnly ? label : undefined; + + const iconStyle = + size === 'small' + ? { + fontSize: 'var(--_badge-icon-size)', + width: 'var(--_badge-icon-size)', + height: 'var(--_badge-icon-size)', + } + : undefined; + + const rootClass = classNames( + styles.root, + styles[phase], + styles[size], + { + [styles.secondary]: variant === 'secondary', + [styles.iconOnly]: iconOnly, + [styles.textOnly]: isTextOnly, + }, + className, + ); + + const badge = ( +
} + className={rootClass} + style={style} + role="status" + aria-label={ariaLabel ?? label} + > + {hasIcon && ( + + + )} + + {!iconOnly && ( + + {label} + + )} +
+ ); + + if (iconOnly) { + return {badge}; + } + + return badge; +}; + +export default DsStatusBadgeV2; diff --git a/packages/design-system/src/components/ds-status-badge-v2/ds-status-badge-v2.types.ts b/packages/design-system/src/components/ds-status-badge-v2/ds-status-badge-v2.types.ts new file mode 100644 index 000000000..2973dddd8 --- /dev/null +++ b/packages/design-system/src/components/ds-status-badge-v2/ds-status-badge-v2.types.ts @@ -0,0 +1,79 @@ +import type { CSSProperties, Ref } from 'react'; +import type { IconType } from '../ds-icon'; +import type { ResponsiveValue } from '../../utils/responsive'; + +export const statusBadgeV2Phases = [ + 'not-started', + 'temporary', + 'in-review', + 'pending', + 'active', + 'execution', + 'result-succeeded', + 'result-warning', + 'result-failed', + 'deprecated', +] as const; + +export type StatusBadgeV2Phase = (typeof statusBadgeV2Phases)[number]; + +export const statusBadgeV2Variants = ['primary', 'secondary'] as const; +export type StatusBadgeV2Variant = (typeof statusBadgeV2Variants)[number]; + +export const statusBadgeV2Sizes = ['medium', 'small'] as const; +export type StatusBadgeV2Size = (typeof statusBadgeV2Sizes)[number]; + +export interface DsStatusBadgeV2SharedProps { + /** + * Lifecycle phase that determines color and default icon. + * Each phase maps to a color palette and a default Material Symbols icon. + */ + phase: StatusBadgeV2Phase; + + label: string; + + /** + * Visual variant controlling background treatment. + * - `'primary'`: tinted background pill or circle + * - `'secondary'`: no background, inline presentation + * @default 'primary' + */ + variant?: StatusBadgeV2Variant; + + /** + * Badge size controlling height, padding, and icon dimensions. Responsive. + * @default 'medium' + */ + size?: StatusBadgeV2Size; + + className?: string; + + style?: CSSProperties; + + ref?: Ref; + + 'aria-label'?: string; +} + +/** + * When `iconOnly` is true, `icon` cannot be `null` — the badge must have an icon + * (either the phase default or a custom one). When `iconOnly` is false or omitted, + * `icon` can be `null` to force a text-only badge. + */ +export type StatusBadgeV2IconOnlyProps = + | { + /** When true, hides the label text and renders icon-only. The label is used as tooltip content instead. */ + iconOnly: true; + /** Override the default phase icon. When omitted, the default icon for the phase is used. */ + icon?: IconType; + } + | { + iconOnly?: false; + icon?: IconType | null; + }; + +export type DsStatusBadgeV2BaseProps = DsStatusBadgeV2SharedProps & StatusBadgeV2IconOnlyProps; + +export type DsStatusBadgeV2Props = Omit & { + size?: ResponsiveValue; +} & StatusBadgeV2IconOnlyProps; diff --git a/packages/design-system/src/components/ds-status-badge-v2/ds-status-badge-v2.unit.test.ts b/packages/design-system/src/components/ds-status-badge-v2/ds-status-badge-v2.unit.test.ts new file mode 100644 index 000000000..7237b2cc1 --- /dev/null +++ b/packages/design-system/src/components/ds-status-badge-v2/ds-status-badge-v2.unit.test.ts @@ -0,0 +1,25 @@ +/* eslint-disable vitest/expect-expect */ +import { describe, expectTypeOf, it } from 'vitest'; +import type { StatusBadgeV2IconOnlyProps } from './ds-status-badge-v2.types'; + +describe('DsStatusBadgeV2 types', () => { + it('allows iconOnly with a custom icon', () => { + expectTypeOf<{ iconOnly: true; icon: () => null }>().toExtend(); + }); + + it('allows iconOnly without icon (phase default)', () => { + expectTypeOf<{ iconOnly: true }>().toExtend(); + }); + + it('allows icon=null when iconOnly is false', () => { + expectTypeOf<{ iconOnly: false; icon: null }>().toExtend(); + }); + + it('allows icon=null when iconOnly is omitted', () => { + expectTypeOf<{ icon: null }>().toExtend(); + }); + + it('prevents iconOnly=true with icon=null', () => { + expectTypeOf<{ iconOnly: true; icon: null }>().not.toExtend(); + }); +}); diff --git a/packages/design-system/src/components/ds-status-badge-v2/index.ts b/packages/design-system/src/components/ds-status-badge-v2/index.ts new file mode 100644 index 000000000..b87eeab5e --- /dev/null +++ b/packages/design-system/src/components/ds-status-badge-v2/index.ts @@ -0,0 +1,13 @@ +import { withResponsiveProps } from '../../utils/responsive'; +import DsStatusBadgeV2Base from './ds-status-badge-v2'; + +export const DsStatusBadgeV2 = withResponsiveProps(DsStatusBadgeV2Base, ['size']); + +export type { + StatusBadgeV2Phase, + StatusBadgeV2Variant, + StatusBadgeV2Size, + DsStatusBadgeV2BaseProps, + DsStatusBadgeV2Props, +} from './ds-status-badge-v2.types'; +export { statusBadgeV2Phases, statusBadgeV2Variants, statusBadgeV2Sizes } from './ds-status-badge-v2.types'; diff --git a/packages/design-system/src/components/ds-status-badge-v2/phase-config.ts b/packages/design-system/src/components/ds-status-badge-v2/phase-config.ts new file mode 100644 index 000000000..a9d8992cb --- /dev/null +++ b/packages/design-system/src/components/ds-status-badge-v2/phase-config.ts @@ -0,0 +1,15 @@ +import type { IconType } from '../ds-icon'; +import type { StatusBadgeV2Phase } from './ds-status-badge-v2.types'; + +export const phaseIconMap: Record = Object.freeze({ + 'not-started': 'lasso_select', + temporary: 'hourglass_empty', + 'in-review': 'document_search', + pending: 'timer_pause', + active: 'power_settings_circle', + execution: 'timelapse', + 'result-succeeded': 'verified', + 'result-warning': 'warning', + 'result-failed': 'cancel', + deprecated: 'inventory_2', +}); diff --git a/packages/design-system/src/components/ds-status-badge/ds-status-badge.types.ts b/packages/design-system/src/components/ds-status-badge/ds-status-badge.types.ts index 28c5e3f24..e560e3b2b 100644 --- a/packages/design-system/src/components/ds-status-badge/ds-status-badge.types.ts +++ b/packages/design-system/src/components/ds-status-badge/ds-status-badge.types.ts @@ -1,13 +1,28 @@ import type { CSSProperties } from 'react'; import type { IconType } from '../ds-icon'; +/** + * @deprecated Use `statusBadgeV2Phases` and `StatusBadgeV2Phase` from `DsStatusBadgeV2` instead. + */ export const dsStatuses = ['active', 'running', 'pending', 'draft', 'inactive', 'warning', 'failed'] as const; +/** + * @deprecated Use `StatusBadgeV2Phase` from `DsStatusBadgeV2` instead. + */ export type DsStatus = (typeof dsStatuses)[number]; +/** + * @deprecated Use `statusBadgeV2Sizes` and `StatusBadgeV2Size` from `DsStatusBadgeV2` instead. + */ export const statusBadgeSizes = ['medium', 'small'] as const; +/** + * @deprecated Use `StatusBadgeV2Size` from `DsStatusBadgeV2` instead. + */ export type StatusBadgeSize = (typeof statusBadgeSizes)[number]; +/** + * @deprecated Use `DsStatusBadgeV2Props` and `DsStatusBadgeV2` instead. + */ export interface DsStatusBadgeProps { /** * The icon of the status badge diff --git a/packages/design-system/src/index.ts b/packages/design-system/src/index.ts index be0efec60..6ed242962 100644 --- a/packages/design-system/src/index.ts +++ b/packages/design-system/src/index.ts @@ -47,6 +47,7 @@ export * from './components/ds-spinner'; export * from './components/ds-split-button'; export * from './components/ds-stack'; export * from './components/ds-status-badge'; +export * from './components/ds-status-badge-v2'; export * from './components/ds-stepper'; export * from './components/ds-system-status'; export * from './components/ds-table'; diff --git a/packages/eslint-plugin/src/__tests__/no-deprecated.test.ts b/packages/eslint-plugin/src/__tests__/no-deprecated.test.ts index 92922f8b2..c63619fb7 100644 --- a/packages/eslint-plugin/src/__tests__/no-deprecated.test.ts +++ b/packages/eslint-plugin/src/__tests__/no-deprecated.test.ts @@ -91,15 +91,34 @@ ruleTester.run('no-deprecated-ds-confirmation', plugin.rules['no-deprecated-ds-c ], }); +ruleTester.run('no-deprecated-ds-status-badge', plugin.rules['no-deprecated-ds-status-badge'], { + valid: [''], + + invalid: [ + { + code: '', + errors: [ + { + message: `DsStatusBadge is deprecated. Use DsStatusBadgeV2 instead.`, + line: 1, + endLine: 1, + column: 2, + endColumn: 15, + }, + ], + }, + ], +}); + ruleTester.run('no-deprecated-ds-system-status', plugin.rules['no-deprecated-ds-system-status'], { - valid: [''], + valid: [''], invalid: [ { code: '', errors: [ { - message: `DsSystemStatus is deprecated. Use DsStatusBadge instead.`, + message: `DsSystemStatus is deprecated. Use DsStatusBadgeV2 instead.`, line: 1, endLine: 1, column: 2, diff --git a/packages/eslint-plugin/src/index.ts b/packages/eslint-plugin/src/index.ts index afa3524f7..d5a9b88b6 100644 --- a/packages/eslint-plugin/src/index.ts +++ b/packages/eslint-plugin/src/index.ts @@ -27,10 +27,16 @@ const eslintPlugin = createPlugin( message: `DsConfirmation is deprecated. Use DsModal instead.`, }, + { + name: 'no-deprecated-ds-status-badge', + selector: JSXElementName('DsStatusBadge'), + message: `DsStatusBadge is deprecated. Use DsStatusBadgeV2 instead.`, + }, + { name: 'no-deprecated-ds-system-status', selector: JSXElementName('DsSystemStatus'), - message: `DsSystemStatus is deprecated. Use DsStatusBadge instead.`, + message: `DsSystemStatus is deprecated. Use DsStatusBadgeV2 instead.`, }, {