Skip to content

Commit 27db9f2

Browse files
committed
Merge branch 'feature/text-badge-custom-color-removable' into q/1.0
2 parents e1a78c1 + 9887230 commit 27db9f2

4 files changed

Lines changed: 163 additions & 6 deletions

File tree

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import React from 'react';
2+
import { render, screen } from '@testing-library/react';
3+
import userEvent from '@testing-library/user-event';
4+
import { getWrapper } from '../../testUtils';
5+
import { TextBadge } from './TextBadge.component';
6+
7+
describe('TextBadge', () => {
8+
const renderBadge = (props: React.ComponentProps<typeof TextBadge>) => {
9+
const { Wrapper } = getWrapper();
10+
render(
11+
<Wrapper>
12+
<TextBadge {...props} />
13+
</Wrapper>,
14+
);
15+
};
16+
17+
it('renders its text', () => {
18+
renderBadge({ text: 'env:prod' });
19+
expect(screen.getByText('env:prod')).toBeInTheDocument();
20+
});
21+
22+
it('does not render a remove button when onRemove is not provided', () => {
23+
renderBadge({ text: 'env:prod' });
24+
expect(screen.queryByRole('button')).not.toBeInTheDocument();
25+
});
26+
27+
it('renders a remove button and calls onRemove when clicked', () => {
28+
const onRemove = jest.fn();
29+
renderBadge({
30+
text: 'env:prod',
31+
onRemove,
32+
removeAriaLabel: 'Remove label env:prod',
33+
});
34+
35+
const removeButton = screen.getByRole('button', {
36+
name: 'Remove label env:prod',
37+
});
38+
expect(removeButton).toBeInTheDocument();
39+
40+
userEvent.click(removeButton);
41+
expect(onRemove).toHaveBeenCalledTimes(1);
42+
});
43+
44+
it('renders with custom colors', () => {
45+
renderBadge({
46+
text: 'env:prod',
47+
customColor: {
48+
text: 'rgb(0, 128, 255)',
49+
backgroundColor: 'rgba(0, 128, 255, 0.16)',
50+
borderColor: 'rgb(0, 128, 255)',
51+
},
52+
});
53+
54+
expect(screen.getByText('env:prod')).toBeInTheDocument();
55+
});
56+
});
Lines changed: 70 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import styled from 'styled-components';
22
import { spacing } from '../../spacing';
33
import { fontWeight } from '../../style/theme';
4+
import { Icon } from '../icon/Icon.component';
45

56
type TextBadgeVariant =
67
| 'statusHealthy'
@@ -10,37 +11,101 @@ type TextBadgeVariant =
1011
| 'infoSecondary'
1112
| 'selectedActive';
1213

13-
const StyledTextBadge = styled.span<{ variant: TextBadgeVariant }>`
14-
${({ theme, variant }) => `
15-
background-color: ${theme[variant]};
14+
// Lets callers drive the badge colors directly instead of picking one of the
15+
// fixed `variant`s — e.g. a per-item color computed at runtime. When set, it
16+
// also gives the badge a border (the `variant` badges have none).
17+
export type TextBadgeCustomColor = {
18+
/** Text color, and border color unless `borderColor` is given. */
19+
text: string;
20+
/** Background color. */
21+
backgroundColor: string;
22+
/** Border color. Falls back to `text`. */
23+
borderColor?: string;
24+
};
25+
26+
const StyledTextBadge = styled.span<{
27+
variant: TextBadgeVariant;
28+
$customColor?: TextBadgeCustomColor;
29+
$removable: boolean;
30+
}>`
31+
${({ theme, variant, $customColor, $removable }) => `
32+
${$removable ? `display: inline-flex; align-items: center; gap: ${spacing.r4};` : ''}
33+
background-color: ${$customColor ? $customColor.backgroundColor : theme[variant]};
1634
color: ${
17-
variant === 'infoSecondary' ? theme.textPrimary : theme.textReverse
35+
$customColor
36+
? $customColor.text
37+
: variant === 'infoSecondary'
38+
? theme.textPrimary
39+
: theme.textReverse
1840
};
41+
${$customColor ? `border: 1px solid ${$customColor.borderColor ?? $customColor.text};` : ''}
1942
padding: 2px ${spacing.r4};
2043
border-radius: 4px;
2144
font-size: 0.9rem;
2245
font-weight: ${fontWeight.bold};
2346
margin: 0 ${spacing.r4} 0 ${spacing.r4};
2447
`}
2548
`;
49+
50+
const RemoveButton = styled.button`
51+
display: inline-flex;
52+
align-items: center;
53+
justify-content: center;
54+
padding: 0;
55+
border: none;
56+
background: none;
57+
color: inherit;
58+
cursor: pointer;
59+
opacity: 0.8;
60+
61+
&:hover {
62+
opacity: 1;
63+
}
64+
`;
65+
2666
type Props = {
2767
text: React.ReactNode;
2868
className?: string;
2969
variant?: TextBadgeVariant;
70+
/** Override the variant colors with explicit ones (and add a border). */
71+
customColor?: TextBadgeCustomColor;
72+
/**
73+
* When provided, a trailing "✕" button is rendered and this is called when
74+
* the user clicks it. The badge does not track any in-flight state itself —
75+
* guard against repeated calls in the handler if needed.
76+
*/
77+
onRemove?: () => void;
78+
/** Accessible label for the remove button. Defaults to "Remove". */
79+
removeAriaLabel?: string;
3080
} & React.HTMLAttributes<HTMLSpanElement>;
3181
export function TextBadge({
3282
text,
3383
variant = 'infoPrimary',
3484
className,
85+
customColor,
86+
onRemove,
87+
removeAriaLabel = 'Remove',
3588
...rest
3689
}: Props) {
3790
return (
3891
<StyledTextBadge
3992
className={['sc-text-badge', className].join(' ')}
4093
variant={variant}
94+
$customColor={customColor}
95+
$removable={Boolean(onRemove)}
4196
{...rest}
4297
>
43-
{text}
98+
{onRemove ? <span>{text}</span> : text}
99+
{onRemove && (
100+
<RemoveButton
101+
type="button"
102+
aria-label={removeAriaLabel}
103+
onClick={onRemove}
104+
className="sc-text-badge-remove"
105+
>
106+
<Icon name="Close" size="xs" />
107+
</RemoveButton>
108+
)}
44109
</StyledTextBadge>
45110
);
46111
}

src/lib/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,10 @@ export { ErrorPage401 } from './components/error-pages/ErrorPage401.component';
4949
export { ErrorPage404 } from './components/error-pages/ErrorPage404.component';
5050
export { ErrorPage500 } from './components/error-pages/ErrorPage500.component';
5151
export { ErrorPageAuth } from './components/error-pages/ErrorPageAuth.component';
52-
export { TextBadge } from './components/textbadge/TextBadge.component';
52+
export {
53+
TextBadge,
54+
type TextBadgeCustomColor,
55+
} from './components/textbadge/TextBadge.component';
5356

5457
export { Layout as Layout2 } from './components/layout/v2';
5558
export { TwoPanelLayout } from './components/layout/v2/panels';

stories/textbadge.stories.tsx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,36 @@ export const Default = {
4040
);
4141
},
4242
};
43+
44+
export const CustomColorAndRemovable = {
45+
render: ({}) => {
46+
return (
47+
<Wrapper>
48+
<Title>Custom color</Title>
49+
<TextBadge
50+
text="env:prod"
51+
customColor={{
52+
text: 'hsl(210, 70%, 65%)',
53+
backgroundColor: 'hsla(210, 70%, 65%, 0.16)',
54+
borderColor: 'hsl(210, 70%, 65%)',
55+
}}
56+
/>
57+
<Title>Removable</Title>
58+
<TextBadge
59+
text="env:prod"
60+
customColor={{
61+
text: 'hsl(150, 70%, 60%)',
62+
backgroundColor: 'hsla(150, 70%, 60%, 0.16)',
63+
}}
64+
onRemove={() => alert('remove env:prod')}
65+
removeAriaLabel="Remove label env:prod"
66+
/>
67+
<TextBadge
68+
text="Removable badge"
69+
variant="infoSecondary"
70+
onRemove={() => alert('remove badge')}
71+
/>
72+
</Wrapper>
73+
);
74+
},
75+
};

0 commit comments

Comments
 (0)