Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
26 changes: 26 additions & 0 deletions bulma-ui/src/components/Badge.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Badge } from './Badge';
import { Avatar } from './Avatar';
Expand Down Expand Up @@ -153,6 +154,31 @@ export const Standalone: Story = {
},
};

export const StandaloneInteractive: Story = {
render: function StandaloneInteractiveExample() {
const [clicks, setClicks] = useState(0);
return (
<Badge content={clicks} showZero onClick={() => setClicks(c => c + 1)} />
);
},
};

export const StandalonePulse: Story = {
render: function StandalonePulseExample() {
return <Badge content={5} color="danger" pulse />;
},
};

export const TextChild: Story = {
render: function TextChildExample() {
return (
<Badge content={3} color="primary">
Inbox
</Badge>
);
},
};

export const CustomNodeContent: Story = {
render: function CustomNodeExample() {
return (
Expand Down
18 changes: 14 additions & 4 deletions bulma-ui/src/components/Badge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ export type BadgeOverlap = 'circle' | 'square';
* @property {string} [className] - Additional CSS classes applied to the root (the wrapper when `children` are present, else the badge pill).
* @property {string} [badgeClassName] - Additional CSS classes applied to the badge pill itself.
* @property {React.ReactNode} [content] - Count, short text, or a custom node to display; omit with `dot` for a plain dot. `max`/`showZero` apply only to numeric content.
* @property {number} [max] - Numeric `content` above this renders as `"{max}+"`. Default `99`.
* @property {number} [max] - Numeric `content` above this renders as `"{max}+"`. Default `99`; a
* negative or non-integer value falls back to the default.
* @property {boolean} [dot] - Render a small dot with no content.
* @property {boolean} [showZero] - Show the badge when `content` is `0`. Default `false`.
* @property {BadgeColor} [color] - Status color. Default `'danger'`.
Expand Down Expand Up @@ -99,17 +100,26 @@ export const Badge: React.FC<BadgeProps> = ({
const hasChildren = children != null && children !== false;

const isZero = typeof content === 'number' && content === 0;
const hasContent = content != null && (!isZero || showZero);
const hasContent =
content != null &&
content !== false &&
content !== true &&
content !== '' &&
(!isZero || showZero);
const shouldRender = dot || hasContent || invisible;

// A negative or non-integer max is nonsensical; fall back to the default
// rather than render e.g. "-1+" (mirrors Avatars' max sanitization).
const sanitizedMax = Number.isInteger(max) && max >= 0 ? max : 99;

const displayValue = useMemo<React.ReactNode>(() => {
if (dot || !hasContent) return undefined;
// max only applies to numeric content; any other node renders verbatim.
if (typeof content === 'number') {
return content > max ? `${max}+` : String(content);
return content > sanitizedMax ? `${sanitizedMax}+` : String(content);
}
return content;
}, [dot, hasContent, content, max]);
}, [dot, hasContent, content, sanitizedMax]);

// Only primitive content produces a meaningful aria-label; a custom node
// supplies its own accessible text, so we leave role="status" unlabeled.
Expand Down
55 changes: 55 additions & 0 deletions bulma-ui/src/components/__tests__/Badge.styles.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Standalone-mode CSS defects (see issue #264) are invisible to jsdom's default
// className assertions — fireEvent bypasses hit-testing and jsdom skips layout.
// These tests compile the real SCSS partial and assert computed style instead.
import * as sass from 'sass';
import path from 'path';
import { render } from '@testing-library/react';
import { Badge } from '../Badge';

let styleEl: HTMLStyleElement;
let compiledCss: string;

beforeAll(() => {
const result = sass.compile(
path.resolve(__dirname, '../../scss/components/_badge.scss'),
{
loadPaths: [path.resolve(__dirname, '../../../../node_modules')],
quietDeps: true,
logger: sass.Logger.silent,
}
);
compiledCss = result.css;
styleEl = document.createElement('style');
styleEl.textContent = result.css;
document.head.appendChild(styleEl);
});

afterAll(() => {
styleEl.remove();
});

describe('Badge standalone styles', () => {
it('keeps the standalone pill clickable (pointer-events: auto)', () => {
const { getByTestId } = render(<Badge content={5} data-testid="badge" />);
expect(getComputedStyle(getByTestId('badge')).pointerEvents).toBe('auto');
});

it('positions a standalone pulse badge so the halo anchors to the pill', () => {
const { getByTestId } = render(
<Badge content={5} pulse data-testid="badge" />
);
expect(getComputedStyle(getByTestId('badge')).position).toBe('relative');
});

// jsdom can't resolve computed style for `::after` (getComputedStyle ignores
// the pseudo-element argument), so assert against the compiled CSS text: the
// pulse halo rule must declare pointer-events: none or it inherits the
// standalone pill's `auto` and its 1.75×-scaled box swallows adjacent clicks.
it('keeps the decorative pulse halo from intercepting clicks (pointer-events: none)', () => {
const pulseRule = compiledCss
.replace(/\s+/g, ' ')
.match(/\.badge\.is-pulse::after\s*\{([^}]*)\}/);
expect(pulseRule).not.toBeNull();
expect(pulseRule?.[1]).toContain('pointer-events: none');
});
});
32 changes: 32 additions & 0 deletions bulma-ui/src/components/__tests__/Badge.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,38 @@ describe('Badge', () => {
expect(screen.getByTestId('badge')).toHaveClass('extra');
});

it('treats content=false as no content', () => {
const { container } = render(<Badge content={false} data-testid="badge" />);
expect(container.querySelectorAll('.badge')).toHaveLength(0);
});

it('treats content=true as no content', () => {
const { container } = render(<Badge content={true} data-testid="badge" />);
expect(container.querySelectorAll('.badge')).toHaveLength(0);
});

it('treats content="" as no content', () => {
const { container } = render(<Badge content="" data-testid="badge" />);
expect(container.querySelectorAll('.badge')).toHaveLength(0);
});

it('sanitizes a negative max back to the default', () => {
render(<Badge content={0} max={-1} showZero />);
expect(screen.getByText('0')).toBeInTheDocument();
expect(screen.queryByText('-1+')).not.toBeInTheDocument();
});

it('sanitizes a non-integer max back to the default', () => {
render(<Badge content={128} max={1.5} />);
expect(screen.getByText('99+')).toBeInTheDocument();
});

it('renders content equal to a sanitized max as-is (boundary)', () => {
render(<Badge content={99} max={-1} />);
expect(screen.getByText('99')).toBeInTheDocument();
expect(screen.queryByText('99+')).not.toBeInTheDocument();
});

it('applies the classPrefix from ConfigProvider', () => {
render(
<ConfigProvider classPrefix="bulma-">
Expand Down
14 changes: 12 additions & 2 deletions bulma-ui/src/scss/components/_badge.scss
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ $badge-animation-duration: 1.4s !default;
.#{iv.$class-prefix}badge-wrapper {
position: relative;
display: inline-flex;
line-height: 0;
}

.#{iv.$class-prefix}badge {
Expand Down Expand Up @@ -93,10 +92,17 @@ $badge-animation-duration: 1.4s !default;
// Standalone (no children) badges no longer emit an is-{position} class, but
// keep this reset as belt-and-braces so a standalone badge always sits inline
// in normal flow regardless of any position/overlap class a caller adds.
// - position: relative (not static) so the pill is the positioned ancestor
// .is-pulse::after resolves against, instead of the halo escaping to cover
// whatever positioned container the badge sits in.
// - pointer-events: auto overrides the base .badge reset (needed for the
// anchored overlay so clicks pass through to the child) because in
// standalone mode the pill itself is the interactive root.
.#{iv.$class-prefix}badge.#{iv.$class-prefix}is-standalone {
position: static;
position: relative;
display: inline-flex;
transform: none;
pointer-events: auto;
Comment thread
claude[bot] marked this conversation as resolved.
Resolved
}

// Overlap adjustment — nudge the badge further in on a round child so it
Expand Down Expand Up @@ -158,6 +164,10 @@ $badge-animation-duration: 1.4s !default;
content: '';
position: absolute;
inset: 0;
// Decorative halo only — never intercept clicks. pointer-events is inherited,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
// so without this the halo picks up the standalone pill's `auto` and its
// 1.75×-scaled box (opacity 0 still hit-tests) swallows adjacent clicks.
pointer-events: none;
border-radius: inherit;
background-color: inherit;
animation: extras-badge-pulse cv.getVar('badge-animation-duration')
Expand Down
36 changes: 35 additions & 1 deletion docs/docs/api/components/badge.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import { Badge } from '@allxsmith/bestax-bulma';
| Prop | Type | Default | Description |
| ---------------- | ---------------------------------------------------------------------------------------------------------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `content` | `React.ReactNode` | — | Count, short text, or a custom node to display; omit with `dot` for a plain dot. `max`/`showZero` apply only to numeric content. |
| `max` | `number` | `99` | Numeric `content` above this renders as `"{max}+"`. |
| `max` | `number` | `99` | Numeric `content` above this renders as `"{max}+"`. A negative or non-integer value falls back to the default. |
| `dot` | `boolean` | `false` | Render a small dot with no content. |
| `showZero` | `boolean` | `false` | Show the badge when `content` is `0`. |
| `color` | `'primary' \| 'link' \| 'info' \| 'success' \| 'warning' \| 'danger' \| 'black' \| 'dark' \| 'light' \| 'white'` | `'danger'` | Status color. |
Expand Down Expand Up @@ -204,6 +204,40 @@ Omit `children` to render the pill inline in normal flow; `position`/`overlap` a
<Badge content={5} color="info" />
```

### Standalone, Interactive

A standalone badge is itself the interactive root — helper props like `onClick` land directly on
the pill, which stays clickable (`pointer-events: auto`).

```tsx live
function example() {
const [clicks, setClicks] = useState(0);

return (
<Badge content={clicks} showZero onClick={() => setClicks(c => c + 1)} />
);
}
```

### Standalone, Pulse

`pulse` on a standalone badge anchors its halo to the pill itself rather than the nearest
positioned ancestor.

```tsx live
<Badge content={5} color="danger" pulse />
```

### Text Child

A plain-text `children` value renders at its natural height alongside the badge.

```tsx live
<Badge content={3} color="primary">
Inbox
</Badge>
```

### Custom Node Content

`content` accepts any `React.ReactNode` — pass an icon or element instead of a count. `max` and
Expand Down
Loading