diff --git a/bulma-ui/src/components/Badge.stories.tsx b/bulma-ui/src/components/Badge.stories.tsx index 9fa4a72f3..1f1fc393f 100644 --- a/bulma-ui/src/components/Badge.stories.tsx +++ b/bulma-ui/src/components/Badge.stories.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import type { Meta, StoryObj } from '@storybook/react-vite'; import { Badge } from './Badge'; import { Avatar } from './Avatar'; @@ -153,6 +154,31 @@ export const Standalone: Story = { }, }; +export const StandaloneInteractive: Story = { + render: function StandaloneInteractiveExample() { + const [clicks, setClicks] = useState(0); + return ( + setClicks(c => c + 1)} /> + ); + }, +}; + +export const StandalonePulse: Story = { + render: function StandalonePulseExample() { + return ; + }, +}; + +export const TextChild: Story = { + render: function TextChildExample() { + return ( + + Inbox + + ); + }, +}; + export const CustomNodeContent: Story = { render: function CustomNodeExample() { return ( diff --git a/bulma-ui/src/components/Badge.tsx b/bulma-ui/src/components/Badge.tsx index 5279f35a0..6c9adfe86 100644 --- a/bulma-ui/src/components/Badge.tsx +++ b/bulma-ui/src/components/Badge.tsx @@ -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'`. @@ -99,17 +100,26 @@ export const Badge: React.FC = ({ 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(() => { 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. diff --git a/bulma-ui/src/components/__tests__/Badge.styles.test.tsx b/bulma-ui/src/components/__tests__/Badge.styles.test.tsx new file mode 100644 index 000000000..20ec4e802 --- /dev/null +++ b/bulma-ui/src/components/__tests__/Badge.styles.test.tsx @@ -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(); + expect(getComputedStyle(getByTestId('badge')).pointerEvents).toBe('auto'); + }); + + it('positions a standalone pulse badge so the halo anchors to the pill', () => { + const { getByTestId } = render( + + ); + 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'); + }); +}); diff --git a/bulma-ui/src/components/__tests__/Badge.test.tsx b/bulma-ui/src/components/__tests__/Badge.test.tsx index ae250889d..301beeda3 100644 --- a/bulma-ui/src/components/__tests__/Badge.test.tsx +++ b/bulma-ui/src/components/__tests__/Badge.test.tsx @@ -224,6 +224,38 @@ describe('Badge', () => { expect(screen.getByTestId('badge')).toHaveClass('extra'); }); + it('treats content=false as no content', () => { + const { container } = render(); + expect(container.querySelectorAll('.badge')).toHaveLength(0); + }); + + it('treats content=true as no content', () => { + const { container } = render(); + expect(container.querySelectorAll('.badge')).toHaveLength(0); + }); + + it('treats content="" as no content', () => { + const { container } = render(); + expect(container.querySelectorAll('.badge')).toHaveLength(0); + }); + + it('sanitizes a negative max back to the default', () => { + render(); + expect(screen.getByText('0')).toBeInTheDocument(); + expect(screen.queryByText('-1+')).not.toBeInTheDocument(); + }); + + it('sanitizes a non-integer max back to the default', () => { + render(); + expect(screen.getByText('99+')).toBeInTheDocument(); + }); + + it('renders content equal to a sanitized max as-is (boundary)', () => { + render(); + expect(screen.getByText('99')).toBeInTheDocument(); + expect(screen.queryByText('99+')).not.toBeInTheDocument(); + }); + it('applies the classPrefix from ConfigProvider', () => { render( diff --git a/bulma-ui/src/scss/components/_badge.scss b/bulma-ui/src/scss/components/_badge.scss index b3bd68b04..45cc8374a 100644 --- a/bulma-ui/src/scss/components/_badge.scss +++ b/bulma-ui/src/scss/components/_badge.scss @@ -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 { @@ -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; } // Overlap adjustment — nudge the badge further in on a round child so it @@ -158,6 +164,11 @@ $badge-animation-duration: 1.4s !default; content: ''; position: absolute; inset: 0; + + // Decorative halo only — never intercept clicks. pointer-events is inherited, + // 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') diff --git a/docs/docs/api/components/badge.md b/docs/docs/api/components/badge.md index ce85b2fa2..13d198f50 100644 --- a/docs/docs/api/components/badge.md +++ b/docs/docs/api/components/badge.md @@ -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. | @@ -204,6 +204,40 @@ Omit `children` to render the pill inline in normal flow; `position`/`overlap` a ``` +### 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 ( + 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 + +``` + +### Text Child + +A plain-text `children` value renders at its natural height alongside the badge. + +```tsx live + + Inbox + +``` + ### Custom Node Content `content` accepts any `React.ReactNode` — pass an icon or element instead of a count. `max` and