Repository: SonarSource/echoes-react
Pull request #309: ECHOES-684 New Badge component
Author: login: jeremy-davis-sonarsource
Created: 2025-04-22T13:28:22Z | Merged: 2025-04-23T10:14:10Z

[ECHOES-684](https://sonarsource.atlassian.net/browse/ECHOES-684)



[ECHOES-684]: https://sonarsource.atlassian.net/browse/ECHOES-684?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ


Diff:
diff --git a/.yarnrc.yml b/.yarnrc.yml
index 65999c895..3c5b177a1 100644
--- a/.yarnrc.yml
+++ b/.yarnrc.yml
@@ -24,3 +24,4 @@ supportedArchitectures:
 tsEnableAutoTypes: true
 
 yarnPath: .yarn/releases/yarn-4.3.1.cjs
+nodeLinker: pnp
\ No newline at end of file
diff --git a/package.json b/package.json
index 1b17ffbd9..ce90e1b89 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@sonarsource/echoes-react",
-  "version": "0.17.0",
+  "version": "0.18.0",
   "type": "module",
   "exports": "./dist/index.js",
   "files": [
diff --git a/src/components/badges/Badge.tsx b/src/components/badges/Badge.tsx
new file mode 100644
index 000000000..a76b85b37
--- /dev/null
+++ b/src/components/badges/Badge.tsx
@@ -0,0 +1,175 @@
+/*
+ * Echoes React
+ * Copyright (C) 2023-2025 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+
+import styled from '@emotion/styled';
+import { forwardRef, PropsWithChildren, useMemo } from 'react';
+
+export enum BadgeSize {
+  Small = 'small',
+  Medium = 'medium',
+}
+
+export enum BadgeVariety {
+  Danger = 'danger',
+  Highlight = 'highlight',
+  Info = 'info',
+  Neutral = 'neutral',
+  Success = 'success',
+  Warning = 'warning',
+}
+
+export interface BadgeProps extends PropsWithChildren {
+  ariaLabel?: string;
+  className?: string;
+  isHighContrast?: boolean;
+  size?: `${BadgeSize}`;
+  variety: `${BadgeVariety}`;
+}
+
+export const Badge = forwardRef<HTMLSpanElement, BadgeProps>((props, ref) => {
+  const {
+    ariaLabel,
+    children,
+    isHighContrast = false,
+    size = BadgeSize.Small,
+    variety,
+    ...otherProps
+  } = props;
+
+  return (
+    <StyledBadge
+      aria-label={ariaLabel}
+      {...otherProps}
+      css={useMemo(
+        () => ({
+          ...(isHighContrast
+            ? BADGE_HIGH_CONTRAST_VARIETY_STYLES[variety]
+            : BADGE_VARIETY_STYLES[variety]),
+          ...BADGE_SIZE_STYLES[size],
+        }),
+        [isHighContrast, size, variety],
+      )}
+      ref={ref}>
+      {children}
+    </StyledBadge>
+  );
+});
+
+Badge.displayName = 'Badge';
+
+const StyledBadge = styled.span`
+  box-sizing: border-box;
+
+  color: var(--badge-color);
+  background-color: var(--badge-background-color);
+
+  font-family: var(--echoes-font-family-sans);
+  font-weight: var(--echoes-font-weight-medium);
+  font-size: var(--badge-font-size);
+  line-height: var(--badge-line-height);
+
+  // Using outline so that the border doesn't take space in the flow
+  outline: var(--badge-border-color) solid var(--echoes-border-width-default);
+  border-radius: var(--echoes-border-radius-200);
+
+  padding: var(--badge-padding);
+  height: var(--badge-height);
+`;
+
+const BADGE_SIZE_STYLES = {
+  [BadgeSize.Small]: {
+    '--badge-padding': 'var(--echoes-dimension-space-25) var(--echoes-dimension-space-50)',
+    '--badge-height': 'var(--echoes-dimension-height-500)',
+    '--badge-font-size': 'var(--echoes-font-size-10)',
+    '--badge-line-height': 'var(--echoes-line-height-10)',
+  },
+  [BadgeSize.Medium]: {
+    '--badge-padding': 'var(--echoes-dimension-space-50) var(--echoes-dimension-space-75)',
+    '--badge-height': 'var(--echoes-dimension-height-700)',
+    '--badge-font-size': 'var(--echoes-font-size-20)',
+    '--badge-line-height': 'var(--echoes-line-height-20)',
+  },
+};
+
+const BADGE_VARIETY_STYLES = {
+  [BadgeVariety.Danger]: {
+    '--badge-color': 'var(--echoes-color-text-danger)',
+    '--badge-border-color': 'var(--echoes-color-border-danger-weak)',
+    '--badge-background-color': 'var(--echoes-color-background-danger-weak-default)',
+  },
+  [BadgeVariety.Highlight]: {
+    '--badge-color': 'var(--echoes-color-text-emphasis)',
+    '--badge-border-color': 'var(--echoes-color-border-emphasis-weak)',
+    '--badge-background-color': 'var(--echoes-color-background-emphasis-weak-default)',
+  },
+  [BadgeVariety.Info]: {
+    '--badge-color': 'var(--echoes-color-text-info)',
+    '--badge-border-color': 'var(--echoes-color-border-info-weak)',
+    '--badge-background-color': 'var(--echoes-color-background-info-weak)',
+  },
+  [BadgeVariety.Neutral]: {
+    '--badge-color': 'var(--echoes-color-text-default)',
+    '--badge-border-color': 'var(--echoes-color-border-weak)',
+    '--badge-background-color': 'var(--echoes-color-background-neutral)',
+  },
+  [BadgeVariety.Success]: {
+    '--badge-color': 'var(--echoes-color-text-success)',
+    '--badge-border-color': 'var(--echoes-color-border-success-weak)',
+    '--badge-background-color': 'var(--echoes-color-background-success-weak)',
+  },
+  [BadgeVariety.Warning]: {
+    '--badge-color': 'var(--echoes-color-text-warning)',
+    '--badge-border-color': 'var(--echoes-color-border-warning-weak)',
+    '--badge-background-color': 'var(--echoes-color-background-warning-weak)',
+  },
+};
+
+const BADGE_HIGH_CONTRAST_VARIETY_STYLES = {
+  [BadgeVariety.Danger]: {
+    '--badge-color': 'var(--echoes-color-text-on-color)',
+    '--badge-border-color': 'transparent',
+    '--badge-background-color': 'var(--echoes-color-background-danger-default)',
+  },
+  [BadgeVariety.Highlight]: {
+    '--badge-color': 'var(--echoes-color-text-on-color)',
+    '--badge-border-color': 'transparent',
+    '--badge-background-color': 'var(--echoes-color-background-emphasis-default-default)',
+  },
+  [BadgeVariety.Info]: {
+    '--badge-color': 'var(--echoes-color-text-on-color)',
+    '--badge-border-color': 'transparent',
+    '--badge-background-color': 'var(--echoes-color-background-info-default)',
+  },
+  [BadgeVariety.Neutral]: {
+    '--badge-color': 'var(--echoes-color-text-on-color)',
+    '--badge-border-color': 'transparent',
+    '--badge-background-color': 'var(--echoes-color-background-inverse)',
+  },
+  [BadgeVariety.Success]: {
+    '--badge-color': 'var(--echoes-color-text-on-color)',
+    '--badge-border-color': 'transparent',
+    '--badge-background-color': 'var(--echoes-color-background-success-default)',
+  },
+  [BadgeVariety.Warning]: {
+    '--badge-color': 'var(--echoes-color-text-on-color-inverse)',
+    '--badge-border-color': 'transparent',
+    '--badge-background-color': 'var(--echoes-color-background-warning-default)',
+  },
+};
diff --git a/src/components/badges/__tests__/Badge-test.tsx b/src/components/badges/__tests__/Badge-test.tsx
new file mode 100644
index 000000000..45eab2cbf
--- /dev/null
+++ b/src/components/badges/__tests__/Badge-test.tsx
@@ -0,0 +1,53 @@
+/*
+ * Echoes React
+ * Copyright (C) 2023-2025 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+
+import { render, screen } from '@testing-library/react';
+import { Badge } from '../Badge';
+
+describe('Badge', () => {
+  it('renders as expected', () => {
+    render(
+      <Badge data-testid="badge" variety="info">
+        badger
+      </Badge>,
+    );
+
+    expect(screen.getByText('badger')).toBeInTheDocument();
+  });
+
+  it('accepts custom ariaLabel and className props', () => {
+    render(
+      <Badge
+        ariaLabel="custom label"
+        className="custom class"
+        isHighContrast
+        size="medium"
+        variety="danger">
+        watch out!
+      </Badge>,
+    );
+
+    const badge = screen.getByText('watch out!');
+
+    expect(badge).toBeInTheDocument();
+    expect(badge).toHaveAttribute('aria-label', 'custom label');
+    expect(badge).toHaveClass('custom class');
+  });
+});
diff --git a/src/components/badges/index.ts b/src/components/badges/index.ts
index 6a0a55f61..b6da17f95 100644
--- a/src/components/badges/index.ts
+++ b/src/components/badges/index.ts
@@ -18,10 +18,12 @@
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 
+export { Badge, BadgeSize, BadgeVariety } from './Badge';
 export { RatingBadge, RatingBadgeRating, RatingBadgeSize } from './RatingBadge';
 export { RatingBadgeButton } from './RatingBadgeButton';
 export { RatingBadgeLink } from './RatingBadgeLink';
 
+export type { BadgeProps } from './Badge';
 export type { RatingBadgeProps } from './RatingBadge';
 export type { RatingBadgeButtonProps } from './RatingBadgeButton';
 export type { RatingBadgeLinkProps } from './RatingBadgeLink';
diff --git a/stories/badges/Badge-stories.tsx b/stories/badges/Badge-stories.tsx
new file mode 100644
index 000000000..ccfd02abe
--- /dev/null
+++ b/stories/badges/Badge-stories.tsx
@@ -0,0 +1,49 @@
+/*
+ * Echoes React
+ * Copyright (C) 2023-2025 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+
+import type { Meta, StoryObj } from '@storybook/react';
+import { Badge } from '../../src';
+import { BasicWrapper } from '../helpers/BasicWrapper';
+
+const meta: Meta<typeof Badge> = {
+  component: Badge,
+
+  parameters: {
+    controls: { exclude: ['className'] },
+  },
+
+  title: 'Echoes/Badges/Badge',
+};
+
+export default meta;
+
+type Story = StoryObj<typeof Badge>;
+
+export const Default: Story = {
+  args: {
+    children: 'Badge!',
+    variety: 'neutral',
+  },
+  render: (args) => (
+    <BasicWrapper>
+      <Badge {...args} />
+    </BasicWrapper>
+  ),
+};


Reviews:
user: login: david-cho-lerat-sonarsource
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T13:42:19Z
user: login: jeremy-davis-sonarsource
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T08:19:09Z
user: login: david-cho-lerat-sonarsource
body: Well done! :muscle: 
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T09:56:56Z

Review comments:
diff_hunk: @@ -0,0 +1,175 @@
+/*
+ * Echoes React
+ * Copyright (C) 2023-2025 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+
+import styled from '@emotion/styled';
+import { forwardRef, PropsWithChildren, useMemo } from 'react';
+
+enum BadgeSize {
+  Small = 'small',
+  Medium = 'medium',
+}
+
+enum BadgeVariety {
+  Neutral = 'neutral',
path: src/components/badges/Badge.tsx
user: login: david-cho-lerat-sonarsource
body: NP: don't we want these enums alpha-sorted?
created_at: 2025-04-22T13:42:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,175 @@
+/*
+ * Echoes React
+ * Copyright (C) 2023-2025 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+
+import styled from '@emotion/styled';
+import { forwardRef, PropsWithChildren, useMemo } from 'react';
+
+export enum BadgeSize {
+  Small = 'small',
+  Medium = 'medium',
+}
+
+export enum BadgeVariety {
+  Danger = 'danger',
+  Highlight = 'highlight',
+  Info = 'info',
+  Neutral = 'neutral',
+  Success = 'success',
+  Warning = 'warning',
+}
+
+export interface BadgeProps extends PropsWithChildren {
+  ariaLabel?: string;
path: src/components/badges/Badge.tsx
user: login: jeremy-davis-sonarsource
body: Actually, I think I should remove the aria-label prop: its role (none) isn't compatible 
created_at: 2025-04-23T08:19:08Z
author_association: CONTRIBUTOR

PR comments:
user: login: netlify[bot]
created_at: 2025-04-22T13:28:44Z
body: ### <span aria-hidden="true">✅</span> Deploy Preview for *echoes-react* ready!


|  Name | Link |
|:-:|------------------------|
|<span aria-hidden="true">🔨</span> Latest commit | 879e3a5b5fc5289b45b3e8061b601a66dcdc3b58 |
|<span aria-hidden="true">🔍</span> Latest deploy log | https://app.netlify.com/sites/echoes-react/deploys/6807c168c72eee0008ddb2bc |
|<span aria-hidden="true">😎</span> Deploy Preview | [https://deploy-preview-309--echoes-react.netlify.app](https://deploy-preview-309--echoes-react.netlify.app) |
|<span aria-hidden="true">📱</span> Preview on mobile | <details><summary> Toggle QR Code... </summary><br /><br />![QR Code](https://app.netlify.com/qr-code/eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1cmwiOiJodHRwczovL2RlcGxveS1wcmV2aWV3LTMwOS0tZWNob2VzLXJlYWN0Lm5ldGxpZnkuYXBwIn0.JdAeDtZwTJYoJKJ3BCuNs9jyjK6b__S9KD6YRtmmzqE)<br /><br />_Use your smartphone camera to open QR code link._</details> |
---
<!-- [echoes-react Preview](https://deploy-preview-309--echoes-react.netlify.app) -->
_To edit notification comments on pull requests, go to your [Netlify site configuration](https://app.netlify.com/sites/echoes-react/configuration/notifications#deploy-webhooks)._
author_association: NONE
user: login: sonarqube-next[bot]
created_at: 2025-04-22T16:21:35Z
body: ## [![Quality Gate failed](https://raw.githubusercontent.com/SonarSource/sonarqube-static-resources/master/v104/checks/QualityGateBadge/failed-16px.png 'Quality Gate failed')](https://next.sonarqube.com/sonarqube/dashboard?id=SonarSource_echoes-react_AYvOIyNg-JQvdKIPB6Ig&pullRequest=309) **Quality Gate failed**  
Failed conditions  
[![](https://raw.githubusercontent.com/SonarSource/sonarqube-static-resources/master/v104/common/failed-16px.png '')](https://next.sonarqube.com/sonarqube/component_measures?id=SonarSource_echoes-react_AYvOIyNg-JQvdKIPB6Ig&pullRequest=309&metric=new_duplicated_lines_density&view=list) [22.0% Duplication on New Code](https://next.sonarqube.com/sonarqube/component_measures?id=SonarSource_echoes-react_AYvOIyNg-JQvdKIPB6Ig&pullRequest=309&metric=new_duplicated_lines_density&view=list) (required ≤ 3%)  
  
[See analysis details on SonarQube](https://next.sonarqube.com/sonarqube/dashboard?id=SonarSource_echoes-react_AYvOIyNg-JQvdKIPB6Ig&pullRequest=309)


author_association: NONE