Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions src/lib/components/textbadge/TextBadge.component.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { getWrapper } from '../../testUtils';
import { TextBadge } from './TextBadge.component';

describe('TextBadge', () => {
const renderBadge = (props: React.ComponentProps<typeof TextBadge>) => {
const { Wrapper } = getWrapper();
render(
<Wrapper>
<TextBadge {...props} />
</Wrapper>,
);
};

it('renders its text', () => {
renderBadge({ text: 'env:prod' });
expect(screen.getByText('env:prod')).toBeInTheDocument();
});

it('does not render a remove button when onRemove is not provided', () => {
renderBadge({ text: 'env:prod' });
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});

it('renders a remove button and calls onRemove when clicked', () => {
const onRemove = jest.fn();
renderBadge({
text: 'env:prod',
onRemove,
removeAriaLabel: 'Remove label env:prod',
});

const removeButton = screen.getByRole('button', {
name: 'Remove label env:prod',
});
expect(removeButton).toBeInTheDocument();

userEvent.click(removeButton);
expect(onRemove).toHaveBeenCalledTimes(1);
});

it('renders with custom colors', () => {
renderBadge({
text: 'env:prod',
customColor: {
text: 'rgb(0, 128, 255)',
backgroundColor: 'rgba(0, 128, 255, 0.16)',
borderColor: 'rgb(0, 128, 255)',
},
});

expect(screen.getByText('env:prod')).toBeInTheDocument();
});
});
75 changes: 70 additions & 5 deletions src/lib/components/textbadge/TextBadge.component.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import styled from 'styled-components';
import { spacing } from '../../spacing';
import { fontWeight } from '../../style/theme';
import { Icon } from '../icon/Icon.component';

type TextBadgeVariant =
| 'statusHealthy'
Expand All @@ -10,37 +11,101 @@ type TextBadgeVariant =
| 'infoSecondary'
| 'selectedActive';

const StyledTextBadge = styled.span<{ variant: TextBadgeVariant }>`
${({ theme, variant }) => `
background-color: ${theme[variant]};
// Lets callers drive the badge colors directly instead of picking one of the
// fixed `variant`s — e.g. a per-item color computed at runtime. When set, it
// also gives the badge a border (the `variant` badges have none).
export type TextBadgeCustomColor = {
Comment thread
damiengillesscality marked this conversation as resolved.
/** Text color, and border color unless `borderColor` is given. */
text: string;
/** Background color. */
backgroundColor: string;
/** Border color. Falls back to `text`. */
borderColor?: string;
};

const StyledTextBadge = styled.span<{
variant: TextBadgeVariant;
$customColor?: TextBadgeCustomColor;
$removable: boolean;
}>`
${({ theme, variant, $customColor, $removable }) => `
${$removable ? `display: inline-flex; align-items: center; gap: ${spacing.r4};` : ''}
background-color: ${$customColor ? $customColor.backgroundColor : theme[variant]};
color: ${
variant === 'infoSecondary' ? theme.textPrimary : theme.textReverse
$customColor
? $customColor.text
: variant === 'infoSecondary'
? theme.textPrimary
: theme.textReverse
};
${$customColor ? `border: 1px solid ${$customColor.borderColor ?? $customColor.text};` : ''}
padding: 2px ${spacing.r4};
border-radius: 4px;
font-size: 0.9rem;
font-weight: ${fontWeight.bold};
margin: 0 ${spacing.r4} 0 ${spacing.r4};
`}
`;

const RemoveButton = styled.button`
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: none;
background: none;
color: inherit;
cursor: pointer;
opacity: 0.8;

&:hover {
opacity: 1;
}
`;

type Props = {
text: React.ReactNode;
className?: string;
variant?: TextBadgeVariant;
/** Override the variant colors with explicit ones (and add a border). */
customColor?: TextBadgeCustomColor;
/**
* When provided, a trailing "✕" button is rendered and this is called when
* the user clicks it. The badge does not track any in-flight state itself —
* guard against repeated calls in the handler if needed.
*/
onRemove?: () => void;
/** Accessible label for the remove button. Defaults to "Remove". */
removeAriaLabel?: string;
} & React.HTMLAttributes<HTMLSpanElement>;
export function TextBadge({
text,
variant = 'infoPrimary',
className,
customColor,
onRemove,
removeAriaLabel = 'Remove',
...rest
}: Props) {
return (
<StyledTextBadge
className={['sc-text-badge', className].join(' ')}
variant={variant}
$customColor={customColor}
$removable={Boolean(onRemove)}
{...rest}
>
{text}
{onRemove ? <span>{text}</span> : text}
{onRemove && (
<RemoveButton
type="button"
aria-label={removeAriaLabel}
onClick={onRemove}
className="sc-text-badge-remove"
>
<Icon name="Close" size="xs" />
</RemoveButton>
)}
</StyledTextBadge>
);
}
5 changes: 4 additions & 1 deletion src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,10 @@ export { ErrorPage401 } from './components/error-pages/ErrorPage401.component';
export { ErrorPage404 } from './components/error-pages/ErrorPage404.component';
export { ErrorPage500 } from './components/error-pages/ErrorPage500.component';
export { ErrorPageAuth } from './components/error-pages/ErrorPageAuth.component';
export { TextBadge } from './components/textbadge/TextBadge.component';
export {
TextBadge,
type TextBadgeCustomColor,
} from './components/textbadge/TextBadge.component';

export { Layout as Layout2 } from './components/layout/v2';
export { TwoPanelLayout } from './components/layout/v2/panels';
Expand Down
33 changes: 33 additions & 0 deletions stories/textbadge.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,36 @@ export const Default = {
);
},
};

export const CustomColorAndRemovable = {
render: ({}) => {
return (
<Wrapper>
<Title>Custom color</Title>
<TextBadge
text="env:prod"
customColor={{
text: 'hsl(210, 70%, 65%)',
backgroundColor: 'hsla(210, 70%, 65%, 0.16)',
borderColor: 'hsl(210, 70%, 65%)',
}}
/>
<Title>Removable</Title>
<TextBadge
text="env:prod"
customColor={{
text: 'hsl(150, 70%, 60%)',
backgroundColor: 'hsla(150, 70%, 60%, 0.16)',
}}
onRemove={() => alert('remove env:prod')}
removeAriaLabel="Remove label env:prod"
/>
<TextBadge
text="Removable badge"
variant="infoSecondary"
onRemove={() => alert('remove badge')}
/>
</Wrapper>
);
},
};
Loading