diff --git a/bulma-ui/src/components/Avatar.stories.tsx b/bulma-ui/src/components/Avatar.stories.tsx new file mode 100644 index 000000000..66a9fa3c7 --- /dev/null +++ b/bulma-ui/src/components/Avatar.stories.tsx @@ -0,0 +1,171 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { Avatar } from './Avatar'; +import { Block } from '../elements/Block'; +import { Icon } from '../elements/Icon'; + +const meta: Meta = { + title: 'Components/Avatar', + component: Avatar, + tags: ['autodocs'], + parameters: { + docs: { + description: { + component: + 'Represents a person or entity as a compact image, falling back automatically from a photo to initials to an icon.', + }, + }, + }, + argTypes: { + src: { + control: 'text', + description: + 'Image URL. On load error (or if absent), falls back to initials, then icon.', + }, + alt: { + control: 'text', + description: + 'Alternate text for the image (required for meaningful images).', + }, + name: { + control: 'text', + description: + 'Derives initials and a deterministic background color when no src/initials is shown.', + }, + initials: { + control: 'text', + description: 'Explicit initials override (else derived from name).', + }, + size: { + control: 'select', + options: [ + '16x16', + '24x24', + '32x32', + '48x48', + '64x64', + '96x96', + '128x128', + ], + description: 'Preset size, or a pixel size when a number.', + }, + shape: { + control: 'select', + options: ['circle', 'rounded', 'square'], + description: 'Avatar shape. Default circle.', + }, + color: { + control: 'select', + options: [ + 'primary', + 'link', + 'info', + 'success', + 'warning', + 'danger', + 'black', + 'dark', + 'light', + 'white', + ], + description: + 'Background color for initials/icon avatars (else auto-derived from name).', + }, + href: { + control: 'text', + description: 'When set, renders the avatar as a link.', + }, + }, +}; +export default meta; +type Story = StoryObj; + +export const Photo: Story = { + render: function PhotoExample() { + return ( + + ); + }, +}; + +export const BrokenImageFallsBackToInitials: Story = { + render: function BrokenImageExample() { + return ( + + ); + }, +}; + +export const Initials: Story = { + render: function InitialsExample() { + return ( + + + + + + + ); + }, +}; + +export const IconFallback: Story = { + render: function IconExample() { + return } color="info" shape="rounded" />; + }, +}; + +export const DefaultFallback: Story = { + render: function DefaultFallbackExample() { + return ; + }, +}; + +export const Shapes: Story = { + render: function ShapesExample() { + return ( + + + + + + ); + }, +}; + +export const Sizes: Story = { + render: function SizesExample() { + return ( + + + + + + + + + ); + }, +}; + +export const Clickable: Story = { + render: function ClickableExample() { + return ; + }, +}; + +export const LazyLoadedImage: Story = { + render: function LazyLoadedExample() { + return ( + + ); + }, +}; diff --git a/bulma-ui/src/components/Avatar.tsx b/bulma-ui/src/components/Avatar.tsx new file mode 100644 index 000000000..72e7b9979 --- /dev/null +++ b/bulma-ui/src/components/Avatar.tsx @@ -0,0 +1,258 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { classNames, usePrefixedClassNames } from '../helpers/classNames'; +import { useBulmaClasses, BulmaClassesProps } from '../helpers/useBulmaClasses'; + +const avatarColors = [ + 'primary', + 'link', + 'info', + 'success', + 'warning', + 'danger', + 'black', + 'dark', + 'light', + 'white', +] as const; + +/** Valid color values for the Avatar component. */ +export type AvatarColor = (typeof avatarColors)[number]; + +const autoAvatarColors: AvatarColor[] = [ + 'primary', + 'link', + 'info', + 'success', + 'warning', + 'danger', +]; + +const avatarSizes = [ + '16x16', + '24x24', + '32x32', + '48x48', + '64x64', + '96x96', + '128x128', +] as const; + +/** Valid preset size values for the Avatar component. */ +export type AvatarSize = (typeof avatarSizes)[number]; + +/** Valid shape values for the Avatar component. */ +export type AvatarShape = 'circle' | 'rounded' | 'square'; + +/** + * Derives a small set of initials from a name (e.g. "Ada Lovelace" -> "AL"). + */ +function getInitialsFromName(name: string): string { + const words = name.trim().split(/\s+/).filter(Boolean); + if (words.length === 0) return ''; + // Iterate by code point (Array.from) so astral-plane characters (emoji, some + // CJK) don't get split into half surrogates. + if (words.length === 1) { + return Array.from(words[0]).slice(0, 2).join('').toUpperCase(); + } + const first = Array.from(words[0])[0]; + const last = Array.from(words[words.length - 1])[0]; + return (first + last).toUpperCase(); +} + +/** + * Deterministically hashes a string to pick one of the auto avatar colors. + */ +function getAutoColor(name: string): AvatarColor { + let hash = 0; + for (let i = 0; i < name.length; i++) { + hash = (hash << 5) - hash + name.charCodeAt(i); + hash |= 0; + } + const index = Math.abs(hash) % autoAvatarColors.length; + return autoAvatarColors[index]; +} + +/** + * Default generic avatar icon shown when there is no image, name, initials, or icon. + */ +function DefaultAvatarIcon() { + return ( + + ); +} + +/** + * Props for the Avatar component. + * + * @property {string} [className] - Additional CSS classes to apply. + * @property {string} [src] - Image URL. On load error (or if absent), falls back to initials, then icon. + * @property {string} [alt] - Alternate text for the image (required for meaningful images). + * @property {string} [name] - Derives initials and a deterministic background color when no `src`/`initials` is shown. + * @property {string} [initials] - Explicit initials override (else derived from `name`). + * @property {React.ReactNode} [icon] - Final fallback rendered when there is no `src`, `name`, or `initials`. + * @property {AvatarSize | number} [size] - Preset size, or a pixel size when a number. + * @property {AvatarShape} [shape] - Avatar shape. Default `'circle'`. + * @property {AvatarColor} [color] - Background color for initials/icon avatars (else auto-derived from `name`). + * @property {React.ElementType} [as] - Element/component to render as. Defaults to `'a'` when `href` is set, else `'figure'`. + * @property {string} [href] - When set, renders the avatar as a link. + * @property {string} [target] - Anchor target (forwarded only when rendering a link). + * @property {string} [rel] - Anchor rel (forwarded only when rendering a link). + * @property {React.ImgHTMLAttributes} [imageProps] - Extra props forwarded to the underlying `` (e.g. `loading`, `crossOrigin`); its `onError` is chained before the fallback fires. + */ +export interface AvatarProps + extends + Omit, 'color'>, + Omit { + className?: string; + src?: string; + alt?: string; + name?: string; + initials?: string; + icon?: React.ReactNode; + size?: AvatarSize | number; + shape?: AvatarShape; + color?: AvatarColor; + as?: React.ElementType; + href?: string; + target?: string; + rel?: string; + imageProps?: React.ImgHTMLAttributes; +} + +/** + * Avatar component for representing a person or entity as a compact image. + * + * Falls back automatically: `src` (image) -> initials (from `initials`/`name`) -> `icon` -> + * a generic default icon. Initials avatars get a stable auto background color derived from + * `name` unless `color` is set. + * + * @function + * @param {AvatarProps} props - Props for the Avatar component. + * @returns {JSX.Element} The rendered avatar element. + * + * @example + * + * @example + * + */ +export const Avatar: React.FC = ({ + className, + src, + alt, + name, + initials, + icon, + size, + shape = 'circle', + color, + as, + href, + target, + rel, + imageProps, + style, + ...props +}) => { + // Tracks the src that failed to load (rather than a plain boolean) so a + // new src is shown again without needing an effect to reset the flag. + const [erroredSrc, setErroredSrc] = useState(undefined); + const imgRef = useRef(null); + + const { bulmaHelperClasses, rest } = useBulmaClasses(props); + + const showImage = !!src && src !== erroredSrc; + + // Catch images that failed before hydration: in SSR/static pages a broken + // image can finish loading before React attaches its onError, so it never + // fires. A complete image with no intrinsic width is a load failure. + useEffect(() => { + const img = imgRef.current; + if (img && img.complete && img.naturalWidth === 0) { + setErroredSrc(src); + } + }, [src]); + + const resolvedInitials = initials + ? initials.toUpperCase() + : name + ? getInitialsFromName(name) + : ''; + const showInitials = !showImage && !!resolvedInitials; + const showIcon = !showImage && !showInitials && !!icon; + const showDefaultIcon = !showImage && !showInitials && !showIcon; + + const resolvedColor = color ?? (name ? getAutoColor(name) : undefined); + + const isPresetSize = typeof size === 'string' && avatarSizes.includes(size); + const sizeStyle = + typeof size === 'number' + ? { width: size, height: size, fontSize: size / 2.5 } + : undefined; + + const avatarClasses = usePrefixedClassNames('avatar', { + [`is-${size}`]: isPresetSize, + [`is-${shape}`]: shape, + [`is-${resolvedColor}`]: + resolvedColor && !showImage && avatarColors.includes(resolvedColor), + }); + + const combinedClasses = classNames( + avatarClasses, + bulmaHelperClasses, + className + ); + + const initialsClass = usePrefixedClassNames('avatar-initials'); + + const Tag: React.ElementType = as ?? (href ? 'a' : 'figure'); + const isInteractive = Tag === 'a' || Tag === 'button'; + + // Only forward link attributes when rendering an anchor or a custom (non-DOM) + // component; a plain `as="div"` must not receive a stray `href`/`target`/`rel`. + const isLinkLike = Tag === 'a' || typeof Tag !== 'string'; + const linkProps = isLinkLike ? { href, target, rel } : {}; + + const a11yProps = showImage + ? {} + : { + ...(isInteractive ? {} : { role: 'img' as const }), + 'aria-label': alt || name || 'Avatar', + }; + + return ( + + {showImage && ( + {alt { + imageProps?.onError?.(e); + setErroredSrc(src); + }} + /> + )} + {showInitials && ( + {resolvedInitials} + )} + {showIcon && icon} + {showDefaultIcon && } + + ); +}; + +Avatar.displayName = 'Avatar'; + +export default Avatar; diff --git a/bulma-ui/src/components/Avatars.stories.tsx b/bulma-ui/src/components/Avatars.stories.tsx new file mode 100644 index 000000000..6abce945a --- /dev/null +++ b/bulma-ui/src/components/Avatars.stories.tsx @@ -0,0 +1,162 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { Avatars } from './Avatars'; +import { Avatar } from './Avatar'; +import { Block } from '../elements/Block'; + +const members = [ + { id: 1, name: 'Ada Lovelace' }, + { id: 2, name: 'Grace Hopper' }, + { id: 3, name: 'Katherine Johnson' }, + { id: 4, name: 'Margaret Hamilton' }, + { id: 5, name: 'Radia Perlman' }, +]; + +const meta: Meta = { + title: 'Components/Avatars', + component: Avatars, + tags: ['autodocs'], + parameters: { + docs: { + description: { + component: + 'An overlapping/stacked group of `Avatar`s with a "+N" surplus bubble when clamped by `max`.', + }, + }, + }, + argTypes: { + max: { + control: 'number', + description: + 'Show only the first `max` children, replacing the overflow with a "+N" surplus avatar. A single overflow avatar is shown directly rather than a pointless "+1".', + }, + spacing: { + control: 'select', + options: ['sm', 'md', 'lg'], + description: + 'Overlap amount between avatars — or the gap when `spaced`. A `sm`/`md`/`lg` preset or a pixel number. Default `md`.', + }, + size: { + control: 'select', + options: [ + '16x16', + '24x24', + '32x32', + '48x48', + '64x64', + '96x96', + '128x128', + ], + description: 'Uniform size applied to every child Avatar.', + }, + shape: { + control: 'select', + options: ['circle', 'rounded', 'square'], + description: 'Uniform shape applied to every child Avatar.', + }, + spaced: { + control: 'boolean', + description: + 'Lay the avatars out side by side (non-overlapping) with `spacing` as the gap. Default `false`.', + }, + }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: function DefaultExample() { + return ( + + {members.map(m => ( + + ))} + + ); + }, +}; + +export const WithSurplus: Story = { + render: function WithSurplusExample() { + return ( + + {members.map(m => ( + + ))} + + ); + }, +}; + +export const UniformShape: Story = { + render: function UniformShapeExample() { + return ( + + {members.map(m => ( + + ))} + + ); + }, +}; + +export const CompoundStatic: Story = { + render: function CompoundStaticExample() { + // `Avatars.Avatar` is the same component as `Avatar`, handy when importing + // only the container. + return ( + + + + + + ); + }, +}; + +export const Spacing: Story = { + render: function SpacingExample() { + return ( + + + {members.map(m => ( + + ))} + + + {members.map(m => ( + + ))} + + + {members.map(m => ( + + ))} + + + ); + }, +}; + +export const NumericSpacing: Story = { + render: function NumericSpacingExample() { + return ( + + {members.map(m => ( + + ))} + + ); + }, +}; + +export const Spaced: Story = { + render: function SpacedExample() { + return ( + + {members.map(m => ( + + ))} + + ); + }, +}; diff --git a/bulma-ui/src/components/Avatars.tsx b/bulma-ui/src/components/Avatars.tsx new file mode 100644 index 000000000..ded3b7f63 --- /dev/null +++ b/bulma-ui/src/components/Avatars.tsx @@ -0,0 +1,127 @@ +import React from 'react'; +import { classNames, usePrefixedClassNames } from '../helpers/classNames'; +import { useBulmaClasses, BulmaClassesProps } from '../helpers/useBulmaClasses'; +import { Avatar, AvatarProps } from './Avatar'; + +/** Valid overlap spacing values for the Avatars component. */ +export type AvatarsSpacing = 'sm' | 'md' | 'lg'; + +/** + * Props for the Avatars component. + * + * @property {string} [className] - Additional CSS classes to apply. + * @property {number} [max] - Show only the first `max` children, replacing the overflow with a "+N" surplus avatar. A single overflow avatar is shown directly rather than as a pointless "+1". + * @property {AvatarProps['size']} [size] - Uniform size applied to every child `Avatar` (and the surplus avatar). + * @property {AvatarProps['shape']} [shape] - Uniform shape applied to every child `Avatar` (and the surplus avatar). + * @property {AvatarsSpacing | number} [spacing] - Space between avatars: a `'sm'`/`'md'`/`'lg'` preset or a pixel `number`. Default `'md'`. + * @property {boolean} [spaced] - Lay the avatars out side by side (non-overlapping) with `spacing` as the gap. Default `false`. + * @property {React.ReactNode} [children] - `Avatar` elements to render inside the group. + */ +export interface AvatarsProps + extends + Omit, 'color'>, + Omit { + className?: string; + max?: number; + size?: AvatarProps['size']; + shape?: AvatarProps['shape']; + spacing?: AvatarsSpacing | number; + spaced?: boolean; + children?: React.ReactNode; +} + +/** + * Avatars component for rendering an overlapping/stacked group of `Avatar`s. + * + * Clamps to `max`, rendering the overflow as a single "+N" surplus avatar. Mirrors the + * `Tags`/`Buttons` sibling-plural-container convention. + * + * @function + * @param {AvatarsProps} props - Props for the Avatars component. + * @returns {JSX.Element} The rendered avatars group. + * + * @example + * + * {members.map(m => )} + * + */ +export const Avatars: React.FC & { Avatar: typeof Avatar } = ({ + className, + max, + size, + shape, + spacing = 'md', + spaced = false, + style, + children, + ...props +}) => { + const { bulmaHelperClasses, rest } = useBulmaClasses({ ...props }); + + // A preset spacing maps to a class; a numeric spacing sets the CSS var inline + // (mirrors Sidebar's inline --bulma-* width var). + const isPresetSpacing = typeof spacing === 'string'; + const avatarsClasses = usePrefixedClassNames('avatars', { + [`is-spacing-${spacing}`]: isPresetSpacing, + 'is-spaced': spaced, + }); + const spacingStyle: React.CSSProperties | undefined = + typeof spacing === 'number' + ? ({ '--bulma-avatars-spacing': `${spacing}px` } as React.CSSProperties) + : undefined; + + const surplusClass = usePrefixedClassNames('is-surplus'); + + const combinedClasses = classNames( + avatarsClasses, + bulmaHelperClasses, + className + ); + + const childArray = React.Children.toArray(children).filter( + React.isValidElement + ) as React.ReactElement[]; + + const maxCount = + typeof max === 'number' && Number.isInteger(max) && max >= 0 + ? max + : undefined; + const overshoot = maxCount !== undefined ? childArray.length - maxCount : 0; + // A "+1" surplus bubble occupies the same slot the hidden avatar would, so + // only collapse into a surplus when it stands in for two or more avatars; a + // single overflow avatar is shown directly. + const clamp = maxCount !== undefined && overshoot >= 2; + const visibleChildren = clamp ? childArray.slice(0, maxCount) : childArray; + const overflowCount = clamp ? overshoot : 0; + + return ( +
+ {visibleChildren.map(child => + // Conditional-spread so an unset group size/shape doesn't clobber a + // child that set its own. + React.cloneElement(child, { + ...(size !== undefined ? { size } : {}), + ...(shape !== undefined ? { shape } : {}), + }) + )} + {overflowCount > 0 && ( + + )} +
+ ); +}; + +Avatars.displayName = 'Avatars'; +Avatars.Avatar = Avatar; + +export default Avatars; diff --git a/bulma-ui/src/components/Badge.stories.tsx b/bulma-ui/src/components/Badge.stories.tsx new file mode 100644 index 000000000..9fa4a72f3 --- /dev/null +++ b/bulma-ui/src/components/Badge.stories.tsx @@ -0,0 +1,195 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { Badge } from './Badge'; +import { Avatar } from './Avatar'; +import { Block } from '../elements/Block'; +import { Icon } from '../elements/Icon'; + +const meta: Meta = { + title: 'Components/Badge', + component: Badge, + tags: ['autodocs'], + parameters: { + docs: { + description: { + component: + 'A small status/count indicator overlaid on the corner of another element (or standalone).', + }, + }, + }, + argTypes: { + content: { + control: 'text', + description: + 'Count, short text, or a custom node to display; max/showZero apply only to numeric content.', + }, + badgeClassName: { + control: 'text', + description: 'Additional CSS classes applied to the badge pill itself.', + }, + max: { + control: 'number', + description: + 'Numeric content above this renders as "{max}+". Default 99.', + }, + dot: { + control: 'boolean', + description: 'Render a small dot with no content.', + }, + showZero: { + control: 'boolean', + description: 'Show the badge when content is 0. Default false.', + }, + color: { + control: 'select', + options: [ + 'primary', + 'link', + 'info', + 'success', + 'warning', + 'danger', + 'black', + 'dark', + 'light', + 'white', + ], + description: 'Status color. Default danger.', + }, + position: { + control: 'select', + options: ['top-right', 'top-left', 'bottom-right', 'bottom-left'], + description: + 'Corner to overlay the badge on, relative to children. Ignored for standalone badges. Default top-right.', + }, + overlap: { + control: 'select', + options: ['circle', 'square'], + description: + 'Nudges the offset for a round (circle) vs rectangular (square) child. Ignored for standalone badges. Default square.', + }, + pulse: { + control: 'boolean', + description: + 'Processing/pulse animation; no-ops under prefers-reduced-motion: reduce.', + }, + invisible: { + control: 'boolean', + description: 'Hide the badge pill without unmounting it.', + }, + }, +}; +export default meta; +type Story = StoryObj; + +export const StatusDot: Story = { + render: function StatusDotExample() { + return ( + + + + ); + }, +}; + +export const UnreadCount: Story = { + render: function UnreadCountExample() { + return ( + + + + ); + }, +}; + +export const Positions: Story = { + render: function PositionsExample() { + return ( + + + + + + + + + + + + + + + ); + }, +}; + +export const Pulse: Story = { + render: function PulseExample() { + return ( + + + + ); + }, +}; + +export const ZeroHiddenByDefault: Story = { + render: function ZeroHiddenExample() { + return ( + + + + + + + + + ); + }, +}; + +export const Standalone: Story = { + render: function StandaloneExample() { + return ; + }, +}; + +export const CustomNodeContent: Story = { + render: function CustomNodeExample() { + return ( + } color="success"> + + + ); + }, +}; + +export const StringContent: Story = { + render: function StringContentExample() { + return ( + + + + ); + }, +}; + +export const StandaloneDot: Story = { + render: function StandaloneDotExample() { + return ; + }, +}; + +export const Invisible: Story = { + render: function InvisibleExample() { + return ( + + + + + + + + + ); + }, +}; diff --git a/bulma-ui/src/components/Badge.tsx b/bulma-ui/src/components/Badge.tsx new file mode 100644 index 000000000..5279f35a0 --- /dev/null +++ b/bulma-ui/src/components/Badge.tsx @@ -0,0 +1,175 @@ +import React, { useMemo } from 'react'; +import { classNames, usePrefixedClassNames } from '../helpers/classNames'; +import { useBulmaClasses, BulmaClassesProps } from '../helpers/useBulmaClasses'; + +const badgeColors = [ + 'primary', + 'link', + 'info', + 'success', + 'warning', + 'danger', + 'black', + 'dark', + 'light', + 'white', +] as const; + +/** Valid color values for the Badge component. */ +export type BadgeColor = (typeof badgeColors)[number]; + +/** Valid corner position values for the Badge component. */ +export type BadgePosition = + 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'; + +/** Valid overlap adjustment values for the Badge component. */ +export type BadgeOverlap = 'circle' | 'square'; + +/** + * Props for the Badge component. + * + * @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 {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'`. + * @property {BadgePosition} [position] - Corner to overlay the badge on, relative to `children`. Default `'top-right'`. + * @property {BadgeOverlap} [overlap] - Nudges the offset for a round (`'circle'`) vs rectangular (`'square'`) child. Default `'square'`. + * @property {boolean} [pulse] - Processing/pulse animation; no-ops under `prefers-reduced-motion: reduce`. + * @property {boolean} [invisible] - Hide the badge without unmounting it. + * @property {React.ReactNode} [children] - The element the badge overlays. Omit to render a standalone badge. + */ +export interface BadgeProps + extends + Omit, 'color' | 'content'>, + Omit { + className?: string; + badgeClassName?: string; + content?: React.ReactNode; + max?: number; + dot?: boolean; + showZero?: boolean; + color?: BadgeColor; + position?: BadgePosition; + overlap?: BadgeOverlap; + pulse?: boolean; + invisible?: boolean; + children?: React.ReactNode; +} + +/** + * Badge component for a small status/count indicator overlaid on the corner of another + * element (or rendered standalone). + * + * Renders `{max}+` when a numeric `content` exceeds `max`, hides at `0` unless `showZero`, + * and no-ops its `pulse` animation under `prefers-reduced-motion: reduce`. + * + * @function + * @param {BadgeProps} props - Props for the Badge component. + * @returns {JSX.Element | null} The rendered badge (and children, if provided). + * + * @example + * + * + * + * @example + * + * + * + */ +export const Badge: React.FC = ({ + className, + badgeClassName, + content, + max = 99, + dot = false, + showZero = false, + color = 'danger', + position = 'top-right', + overlap = 'square', + pulse = false, + invisible = false, + children, + ...props +}) => { + const { bulmaHelperClasses, rest } = useBulmaClasses(props); + + const hasChildren = children != null && children !== false; + + const isZero = typeof content === 'number' && content === 0; + const hasContent = content != null && (!isZero || showZero); + const shouldRender = dot || hasContent || invisible; + + 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; + }, [dot, hasContent, content, max]); + + // Only primitive content produces a meaningful aria-label; a custom node + // supplies its own accessible text, so we leave role="status" unlabeled. + const ariaLabel = + typeof displayValue === 'string' || typeof displayValue === 'number' + ? String(displayValue) + : undefined; + + // Both prefixed-classname hooks stay above the early returns (Rules of Hooks). + const wrapperClass = usePrefixedClassNames('badge-wrapper'); + const badgeClasses = usePrefixedClassNames('badge', { + [`is-${color}`]: !!color && badgeColors.includes(color), + // Corner position / overlap only mean anything inside a wrapper; emitting + // them on a standalone badge shifts it out of normal flow via the transform. + [`is-${position}`]: !!position && hasChildren, + [`is-overlap-${overlap}`]: !!overlap && hasChildren, + 'is-standalone': !hasChildren, + 'is-dot': dot, + 'is-pulse': pulse, + 'is-invisible': invisible, + }); + + // badgeClassName is a plain (unprefixed) slot merged onto the pill, mirroring + // Tooltip's tooltipClassName. Standalone badges have no wrapper, so the pill + // is the root and takes className/helpers/rest directly. + const pillClass = hasChildren + ? classNames(badgeClasses, badgeClassName) + : classNames(badgeClasses, badgeClassName, bulmaHelperClasses, className); + + const a11yProps = dot + ? { 'aria-hidden': true as const } + : { + role: 'status' as const, + ...(ariaLabel !== undefined ? { 'aria-label': ariaLabel } : {}), + }; + + const indicator = shouldRender ? ( + + {!dot && displayValue} + + ) : null; + + if (!hasChildren) { + return indicator; + } + + // Always render the wrapper when there are children so the root keeps + // className/helpers/rest (data-testid, id, onClick, ...) even when the badge + // itself is hidden. + return ( + + {children} + {indicator} + + ); +}; + +Badge.displayName = 'Badge'; + +export default Badge; diff --git a/bulma-ui/src/components/__tests__/Avatar.test.tsx b/bulma-ui/src/components/__tests__/Avatar.test.tsx new file mode 100644 index 000000000..1f3838b32 --- /dev/null +++ b/bulma-ui/src/components/__tests__/Avatar.test.tsx @@ -0,0 +1,234 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { Avatar } from '../Avatar'; +import { ConfigProvider } from '../../helpers/Config'; + +describe('Avatar', () => { + it('renders an image when src is provided', () => { + render(); + const img = screen.getByRole('img', { name: 'Ada Lovelace' }); + expect(img.tagName).toBe('IMG'); + expect(img).toHaveAttribute('src', '/photo.jpg'); + }); + + it('renders an image with an empty alt when neither alt nor name is provided', () => { + const { container } = render(); + expect(container.querySelector('img')).toHaveAttribute('alt', ''); + }); + + it('falls back to the icon when a whitespace-only name yields no initials', () => { + render(} />); + expect(screen.getByTestId('fallback-icon')).toBeInTheDocument(); + }); + + it('falls back to initials when the image fails to load', () => { + render(); + const img = screen.getByRole('img', { hidden: true }) as HTMLImageElement; + fireEvent.error(img); + expect(screen.getByText('AL')).toBeInTheDocument(); + }); + + it('resets the error state when src changes', () => { + const { rerender } = render( + + ); + const img = screen.getByRole('img', { hidden: true }) as HTMLImageElement; + fireEvent.error(img); + expect(screen.getByText('AL')).toBeInTheDocument(); + + rerender(); + expect(screen.getByRole('img', { name: 'Ada Lovelace' })).toHaveAttribute( + 'src', + '/fixed.jpg' + ); + }); + + it('derives initials from a single-word name', () => { + render(); + expect(screen.getByText('CH')).toBeInTheDocument(); + }); + + it('derives initials by code point for astral-plane names', () => { + render(); + // Array.from keeps whole code points, so no half-surrogate initials. + expect(screen.getByText('😀🎉')).toBeInTheDocument(); + }); + + it('falls back to initials when the image already failed before hydration', () => { + const completeSpy = jest + .spyOn(HTMLImageElement.prototype, 'complete', 'get') + .mockReturnValue(true); + const widthSpy = jest + .spyOn(HTMLImageElement.prototype, 'naturalWidth', 'get') + .mockReturnValue(0); + render(); + expect(screen.getByText('AL')).toBeInTheDocument(); + completeSpy.mockRestore(); + widthSpy.mockRestore(); + }); + + it('forwards imageProps to the underlying img', () => { + render( + + ); + const img = screen.getByRole('img', { name: 'Ada' }); + expect(img).toHaveAttribute('loading', 'lazy'); + }); + + it('chains imageProps.onError before falling back', () => { + const onError = jest.fn(); + render( + + ); + const img = screen.getByRole('img', { hidden: true }) as HTMLImageElement; + fireEvent.error(img); + expect(onError).toHaveBeenCalledTimes(1); + expect(screen.getByText('AL')).toBeInTheDocument(); + }); + + it('prefers explicit initials over a derived name', () => { + render(); + expect(screen.getByText('XY')).toBeInTheDocument(); + }); + + it('falls back to the provided icon when there is no src/name/initials', () => { + render(} />); + expect(screen.getByTestId('fallback-icon')).toBeInTheDocument(); + }); + + it('renders a generic default icon when nothing else is provided', () => { + const { container } = render(); + expect(container.querySelector('svg')).toBeInTheDocument(); + }); + + it('applies a deterministic auto color from the name', () => { + const colorPattern = /is-(primary|link|info|success|warning|danger)\b/; + const { container: c1 } = render(); + const { container: c2 } = render(); + const class1 = (c1.firstChild as HTMLElement).className; + const class2 = (c2.firstChild as HTMLElement).className; + expect(class1).toMatch(colorPattern); + expect(class1.match(colorPattern)?.[0]).toBe( + class2.match(colorPattern)?.[0] + ); + }); + + it('lets an explicit color override the auto color', () => { + render(); + expect(screen.getByTestId('avatar')).toHaveClass('is-info'); + }); + + it('applies the shape class (defaults to circle)', () => { + render(); + expect(screen.getByTestId('avatar')).toHaveClass('is-circle'); + }); + + it('applies a custom shape', () => { + render(); + expect(screen.getByTestId('avatar')).toHaveClass('is-square'); + }); + + it('applies a preset size class', () => { + render(); + expect(screen.getByTestId('avatar')).toHaveClass('is-64x64'); + }); + + it('applies an inline pixel size when size is a number', () => { + render(); + const avatar = screen.getByTestId('avatar'); + expect(avatar).not.toHaveClass('is-20'); + expect(avatar).toHaveStyle({ width: '20px', height: '20px' }); + }); + + it('renders as a figure by default', () => { + const { container } = render(); + expect(container.firstChild?.nodeName).toBe('FIGURE'); + }); + + it('renders as a link when href is provided', () => { + render(); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', 'https://example.com'); + }); + + it('forwards target and rel when rendering a link', () => { + render( + + ); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', 'noopener'); + }); + + it('does not forward href to a non-anchor element rendered via as', () => { + const { container } = render( + + ); + const el = container.firstChild as HTMLElement; + expect(el.nodeName).toBe('DIV'); + expect(el).not.toHaveAttribute('href'); + }); + + it('forwards href to a custom component rendered via as', () => { + const CustomLink: React.FC<{ + href?: string; + children?: React.ReactNode; + }> = ({ href, children, ...rest }) => ( + + {children} + + ); + render(); + expect(screen.getByTestId('custom')).toHaveAttribute( + 'href', + 'https://example.com' + ); + }); + + it('respects an explicit as override', () => { + const { container } = render(); + expect(container.firstChild?.nodeName).toBe('DIV'); + }); + + it('renders as a button without a redundant role but keeps the aria-label', () => { + render(); + // A button is already interactive, so no role="img" is added, but the + // accessible name still comes from the aria-label. + const button = screen.getByRole('button', { name: 'Ada Lovelace' }); + expect(button.tagName).toBe('BUTTON'); + expect(button).not.toHaveAttribute('role', 'img'); + }); + + it('passes Bulma helper props through', () => { + render(); + expect(screen.getByTestId('avatar')).toHaveClass('m-3'); + }); + + it('applies a custom className', () => { + render(); + expect(screen.getByTestId('avatar')).toHaveClass('extra'); + }); + + it('sets role and aria-label when not showing an image', () => { + render(); + expect( + screen.getByRole('img', { name: 'Ada Lovelace' }) + ).toBeInTheDocument(); + }); + + it('applies the classPrefix from ConfigProvider', () => { + render( + + + + ); + const avatar = screen.getByTestId('avatar'); + expect(avatar).toHaveClass('bulma-avatar'); + expect(avatar).not.toHaveClass('avatar'); + }); +}); diff --git a/bulma-ui/src/components/__tests__/Avatars.test.tsx b/bulma-ui/src/components/__tests__/Avatars.test.tsx new file mode 100644 index 000000000..fc26227f9 --- /dev/null +++ b/bulma-ui/src/components/__tests__/Avatars.test.tsx @@ -0,0 +1,261 @@ +import { render, screen } from '@testing-library/react'; +import { Avatars } from '../Avatars'; +import { Avatar } from '../Avatar'; +import { ConfigProvider } from '../../helpers/Config'; + +describe('Avatars', () => { + it('renders all children when max is not set', () => { + render( + + + + + ); + expect(screen.getByText('AL')).toBeInTheDocument(); + expect(screen.getByText('GH')).toBeInTheDocument(); + }); + + it('clamps to max and renders a surplus avatar', () => { + render( + + + + + + + ); + expect(screen.getByText('AL')).toBeInTheDocument(); + expect(screen.getByText('GH')).toBeInTheDocument(); + expect(screen.queryByText('KJ')).not.toBeInTheDocument(); + expect(screen.getByText('+2')).toBeInTheDocument(); + }); + + it('collapses every child into the surplus when max is 0', () => { + render( + + + + + + ); + expect(screen.queryByText('AL')).not.toBeInTheDocument(); + expect(screen.getByText('+3')).toBeInTheDocument(); + }); + + it('ignores a negative max and clamps nothing', () => { + render( + + + + + + ); + expect(screen.getByText('AL')).toBeInTheDocument(); + expect(screen.getByText('GH')).toBeInTheDocument(); + expect(screen.getByText('KJ')).toBeInTheDocument(); + expect(screen.queryByText(/^\+/)).not.toBeInTheDocument(); + }); + + it('gives the surplus avatar an accessible name of "{N} more"', () => { + render( + + + + + + + ); + expect(screen.getByRole('img', { name: '2 more' })).toBeInTheDocument(); + }); + + it('renders no surplus avatar when max is not exceeded', () => { + render( + + + + + ); + expect(screen.queryByText(/^\+/)).not.toBeInTheDocument(); + }); + + it('applies a uniform size to every child and the surplus avatar', () => { + render( + + + + + + ); + expect(screen.getByTestId('first')).toHaveClass('is-64x64'); + expect(screen.getByText('+2').closest('figure')).toHaveClass('is-64x64'); + }); + + it('applies a uniform shape to every child and the surplus avatar', () => { + render( + + + + + + ); + expect(screen.getByTestId('first')).toHaveClass('is-square'); + expect(screen.getByText('+2').closest('figure')).toHaveClass('is-square'); + }); + + it('lets a child keep its own size when the group size is unset', () => { + render( + + + + ); + expect(screen.getByTestId('first')).toHaveClass('is-32x32'); + }); + + it('lets a child keep its own shape when the group shape is unset', () => { + render( + + + + ); + expect(screen.getByTestId('first')).toHaveClass('is-square'); + }); + + it('shows a single overflow avatar directly instead of a pointless "+1"', () => { + render( + + + + + + + ); + // Four children, max 3 — collapsing to "+1" saves no space, so the fourth + // avatar renders directly and no surplus bubble appears. + expect(screen.getByText('MH')).toBeInTheDocument(); + expect(screen.queryByText(/^\+/)).not.toBeInTheDocument(); + }); + + it('exposes Avatar as a compound static', () => { + expect(Avatars.Avatar).toBe(Avatar); + }); + + it('applies the spacing class (defaults to md)', () => { + const { container } = render( + + + + ); + expect(container.firstChild).toHaveClass('is-spacing-md'); + }); + + it('applies a custom spacing', () => { + const { container } = render( + + + + ); + expect(container.firstChild).toHaveClass('is-spacing-lg'); + }); + + it('sets an inline spacing var and no spacing class for a numeric spacing', () => { + const { container } = render( + + + + ); + const el = container.firstChild as HTMLElement; + expect(el).not.toHaveClass('is-spacing-16'); + expect(el.style.getPropertyValue('--bulma-avatars-spacing')).toBe('16px'); + }); + + it('applies the is-spaced class for the non-overlapping mode', () => { + const { container } = render( + + + + + ); + expect(container.firstChild).toHaveClass('is-spaced'); + }); + + it('preserves a caller style alongside the spacing var', () => { + const { container } = render( + + + + ); + const el = container.firstChild as HTMLElement; + expect(el.style.getPropertyValue('--bulma-avatars-spacing')).toBe('12px'); + expect(el).toHaveStyle({ marginTop: '4px' }); + }); + + it('passes Bulma helper props through', () => { + const { container } = render( + + + + ); + expect(container.firstChild).toHaveClass('m-3'); + }); + + it('applies a custom className', () => { + const { container } = render( + + + + ); + expect(container.firstChild).toHaveClass('extra'); + }); + + it('renders children with an explicit key', () => { + const members = [ + { id: 'a', name: 'Ada Lovelace' }, + { id: 'b', name: 'Grace Hopper' }, + ]; + render( + + {members.map(member => ( + + ))} + + ); + expect(screen.getByText('AL')).toBeInTheDocument(); + expect(screen.getByText('GH')).toBeInTheDocument(); + }); + + it('ignores non-element children', () => { + render( + + {null} + {'text node'} + + + ); + expect(screen.getByText('AL')).toBeInTheDocument(); + }); + + it('applies the classPrefix from ConfigProvider', () => { + const { container } = render( + + + + + + ); + expect(container.firstChild).toHaveClass('bulma-avatars'); + expect(container.firstChild).not.toHaveClass('avatars'); + }); + + it('ignores a fractional max and clamps nothing', () => { + render( + + + + + + ); + expect(screen.getByText('AL')).toBeInTheDocument(); + expect(screen.getByText('GH')).toBeInTheDocument(); + expect(screen.getByText('KJ')).toBeInTheDocument(); + }); +}); diff --git a/bulma-ui/src/components/__tests__/Badge.test.tsx b/bulma-ui/src/components/__tests__/Badge.test.tsx new file mode 100644 index 000000000..ae250889d --- /dev/null +++ b/bulma-ui/src/components/__tests__/Badge.test.tsx @@ -0,0 +1,237 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { Badge } from '../Badge'; +import { ConfigProvider } from '../../helpers/Config'; + +describe('Badge', () => { + it('renders content overlaid on children', () => { + render( + + child + + ); + expect(screen.getByTestId('child')).toBeInTheDocument(); + expect(screen.getByText('5')).toBeInTheDocument(); + }); + + it('renders content above max as "{max}+"', () => { + render(); + expect(screen.getByText('99+')).toBeInTheDocument(); + }); + + it('renders numeric content at or below max as-is', () => { + render(); + expect(screen.getByText('42')).toBeInTheDocument(); + }); + + it('renders numeric content equal to max as-is (boundary)', () => { + render(); + expect(screen.getByText('99')).toBeInTheDocument(); + expect(screen.queryByText('99+')).not.toBeInTheDocument(); + }); + + it('renders string content as-is, ignoring max', () => { + render(); + expect(screen.getByText('NEW')).toBeInTheDocument(); + }); + + it('renders a custom ReactNode as content', () => { + render(★} />); + expect(screen.getByTestId('node')).toBeInTheDocument(); + }); + + it('ignores max/showZero for non-numeric content and omits the numeric aria-label', () => { + render(} max={5} />); + const status = screen.getByRole('status'); + expect(status).not.toHaveAttribute('aria-label'); + expect(screen.getByTestId('icon')).toBeInTheDocument(); + }); + + it('treats null content as no content', () => { + const { container } = render( + + child + + ); + expect(container.querySelectorAll('.badge')).toHaveLength(0); + }); + + it('hides at content=0 by default', () => { + const { container } = render( + + child + + ); + expect(container.querySelectorAll('.badge')).toHaveLength(0); + }); + + it('shows at content=0 when showZero is set', () => { + render( + + child + + ); + expect(screen.getByText('0')).toBeInTheDocument(); + }); + + it('renders a dot with no content text', () => { + const { container } = render( + + child + + ); + const dot = container.querySelector('.is-dot'); + expect(dot).toBeInTheDocument(); + expect(dot).toHaveTextContent(''); + }); + + it('marks a dot as aria-hidden', () => { + const { container } = render( + + child + + ); + expect(container.querySelector('.is-dot')).toHaveAttribute( + 'aria-hidden', + 'true' + ); + }); + + it('announces count content via role=status and aria-label', () => { + render( + + child + + ); + const status = screen.getByRole('status'); + expect(status).toHaveAttribute('aria-label', '7'); + }); + + it('renders standalone without children', () => { + render(); + expect(screen.getByText('5')).toBeInTheDocument(); + expect(screen.getByText('5')).toHaveClass('is-standalone', 'is-info'); + }); + + it('renders nothing when there is no content, no dot, and no children', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('renders only the children when the indicator is hidden', () => { + render( + + child + + ); + expect(screen.getByTestId('child')).toBeInTheDocument(); + }); + + it('applies the default color (danger) and position (top-right)', () => { + const { container } = render( + + child + + ); + expect(container.querySelector('.badge')).toHaveClass( + 'is-danger', + 'is-top-right' + ); + }); + + it('applies a custom position', () => { + const { container } = render( + + child + + ); + expect(container.querySelector('.badge')).toHaveClass('is-bottom-left'); + }); + + it('applies a custom overlap', () => { + const { container } = render( + + child + + ); + expect(container.querySelector('.badge')).toHaveClass('is-overlap-circle'); + }); + + it('does not emit position/overlap classes on a standalone badge', () => { + render(); + const badge = screen.getByTestId('badge'); + expect(badge).toHaveClass('is-standalone'); + expect(badge).not.toHaveClass('is-top-right'); + expect(badge).not.toHaveClass('is-overlap-square'); + }); + + it('applies the pulse class', () => { + render(); + expect(screen.getByTestId('badge')).toHaveClass('is-pulse'); + }); + + it('hides the badge without unmounting it when invisible', () => { + const { container } = render( + + child + + ); + expect(screen.getByTestId('child')).toBeInTheDocument(); + // The pass-through data-testid lands on the wrapper root; the pill itself + // carries is-invisible. + expect(screen.getByTestId('badge')).toHaveClass('badge-wrapper'); + expect(container.querySelector('.badge')).toHaveClass('is-invisible'); + }); + + it('keeps the wrapper (and its rest props) when the badge is hidden', () => { + const onClick = jest.fn(); + render( + + child + + ); + const wrapper = screen.getByTestId('badge'); + expect(wrapper).toHaveClass('badge-wrapper'); + expect(wrapper).toHaveAttribute('id', 'root'); + expect(screen.getByTestId('child')).toBeInTheDocument(); + fireEvent.click(wrapper); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('routes className and rest to the wrapper, badgeClassName to the pill', () => { + const { container } = render( + + child + + ); + expect(container.querySelector('.badge-wrapper')).toHaveClass('wrap-extra'); + const pill = container.querySelector('.badge'); + expect(pill).toHaveClass('pill-extra'); + expect(pill).not.toHaveClass('wrap-extra'); + }); + + it('renders when invisible even without content or dot', () => { + const { container } = render(); + expect(container.querySelector('.is-invisible')).toBeInTheDocument(); + }); + + it('passes Bulma helper props through', () => { + render(); + expect(screen.getByTestId('badge')).toHaveClass('m-3'); + }); + + it('applies a custom className', () => { + render(); + expect(screen.getByTestId('badge')).toHaveClass('extra'); + }); + + it('applies the classPrefix from ConfigProvider', () => { + render( + + + + ); + const badge = screen.getByTestId('badge'); + expect(badge).toHaveClass('bulma-badge'); + expect(badge).not.toHaveClass('badge'); + }); +}); diff --git a/bulma-ui/src/index.ts b/bulma-ui/src/index.ts index 7f9b8f403..8ddce605b 100644 --- a/bulma-ui/src/index.ts +++ b/bulma-ui/src/index.ts @@ -1,6 +1,9 @@ export * from './columns/Column'; export * from './columns/Columns'; +export * from './components/Avatar'; +export * from './components/Avatars'; +export * from './components/Badge'; export * from './components/Breadcrumb'; export * from './components/Card'; export * from './components/Dropdown'; diff --git a/bulma-ui/src/scss/components/_avatar.scss b/bulma-ui/src/scss/components/_avatar.scss new file mode 100644 index 000000000..8a27f7b06 --- /dev/null +++ b/bulma-ui/src/scss/components/_avatar.scss @@ -0,0 +1,121 @@ +// Avatar component styles +@use 'bulma/sass/utilities/initial-variables' as iv; +@use 'bulma/sass/utilities/css-variables' as cv; +@use '../mixins' as *; + +// Avatar-specific SCSS variables +$avatar-size: 48px !default; +$avatar-background: cv.getVar('background') !default; +$avatar-color: cv.getVar('text') !default; +$avatar-weight: cv.getVar('weight-semibold') !default; +$avatar-rounded-radius: cv.getVar('radius-large') !default; + +.#{iv.$class-prefix}avatar { + @include cv.register-vars( + ( + 'avatar-size': #{$avatar-size}, + 'avatar-background': #{$avatar-background}, + 'avatar-color': #{$avatar-color}, + 'avatar-weight': #{$avatar-weight}, + 'avatar-rounded-radius': #{$avatar-rounded-radius}, + ) + ); +} + +.#{iv.$class-prefix}avatar { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + overflow: hidden; + flex-shrink: 0; + width: cv.getVar('avatar-size'); + height: cv.getVar('avatar-size'); + background-color: cv.getVar('avatar-background'); + color: cv.getVar('avatar-color'); + font-weight: cv.getVar('avatar-weight'); + vertical-align: middle; + user-select: none; + margin: 0; + + img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + } + + svg { + display: block; + } +} + +a.#{iv.$class-prefix}avatar, +button.#{iv.$class-prefix}avatar { + cursor: pointer; + border: none; + padding: 0; + + &:focus-visible { + @include extras-focus-outline; + } +} + +.#{iv.$class-prefix}avatar-initials { + line-height: 1; + text-transform: uppercase; +} + +// Shapes +.#{iv.$class-prefix}avatar.#{iv.$class-prefix}is-circle { + border-radius: cv.getVar('radius-rounded'); +} + +.#{iv.$class-prefix}avatar.#{iv.$class-prefix}is-rounded { + border-radius: cv.getVar('avatar-rounded-radius'); +} + +.#{iv.$class-prefix}avatar.#{iv.$class-prefix}is-square { + border-radius: 0; +} + +// Preset sizes (mirrors Bulma's image fixed-size list) +@each $size in 16, 24, 32, 48, 64, 96, 128 { + .#{iv.$class-prefix}avatar.#{iv.$class-prefix}is-#{$size}x#{$size} { + width: #{$size}px; + height: #{$size}px; + font-size: calc(#{$size}px / 2.5); + } +} + +// Color variants — background + a contrasting foreground (mirrors Toast's approach). +@each $name in 'primary', 'link', 'info', 'success', 'warning', 'danger' { + .#{iv.$class-prefix}avatar.#{iv.$class-prefix}is-#{$name} { + background-color: cv.getVar($name); + color: hsl( + cv.getVar($name, '', '-h'), + cv.getVar($name, '', '-s'), + cv.getVar($name, '', '-invert-l') + ); + } +} + +.#{iv.$class-prefix}avatar.#{iv.$class-prefix}is-black { + background-color: cv.getVar('black'); + color: cv.getVar('white'); +} + +.#{iv.$class-prefix}avatar.#{iv.$class-prefix}is-dark { + background-color: cv.getVar('dark'); + color: cv.getVar('light'); +} + +.#{iv.$class-prefix}avatar.#{iv.$class-prefix}is-light { + background-color: cv.getVar('light'); + color: cv.getVar('dark'); +} + +.#{iv.$class-prefix}avatar.#{iv.$class-prefix}is-white { + background-color: cv.getVar('white'); + color: cv.getVar('black'); +} diff --git a/bulma-ui/src/scss/components/_avatars.scss b/bulma-ui/src/scss/components/_avatars.scss new file mode 100644 index 000000000..31321cbf8 --- /dev/null +++ b/bulma-ui/src/scss/components/_avatars.scss @@ -0,0 +1,73 @@ +// Avatars component styles — an overlapping/stacked Avatar group +@use 'bulma/sass/utilities/initial-variables' as iv; +@use 'bulma/sass/utilities/css-variables' as cv; + +// Avatars-specific SCSS variables +$avatars-ring-color: cv.getVar('scheme-main') !default; +$avatars-ring-width: 2px !default; +$avatars-spacing-sm: 0.5rem !default; +$avatars-spacing-md: 0.75rem !default; +$avatars-spacing-lg: 1rem !default; + +.#{iv.$class-prefix}avatars { + @include cv.register-vars( + ( + 'avatars-ring-color': #{$avatars-ring-color}, + 'avatars-ring-width': #{$avatars-ring-width}, + 'avatars-spacing': #{$avatars-spacing-md}, + ) + ); +} + +// Re-register the single spacing var per preset. A numeric `spacing` prop sets +// --bulma-avatars-spacing inline instead (mirrors Sidebar's inline width var). +.#{iv.$class-prefix}avatars.#{iv.$class-prefix}is-spacing-sm { + @include cv.register-vars( + ( + 'avatars-spacing': #{$avatars-spacing-sm}, + ) + ); +} + +.#{iv.$class-prefix}avatars.#{iv.$class-prefix}is-spacing-lg { + @include cv.register-vars( + ( + 'avatars-spacing': #{$avatars-spacing-lg}, + ) + ); +} + +.#{iv.$class-prefix}avatars { + display: inline-flex; + align-items: center; + + .#{iv.$class-prefix}avatar { + box-shadow: 0 0 0 cv.getVar('avatars-ring-width') + cv.getVar('avatars-ring-color'); + + &:not(:first-child) { + margin-left: calc(-1 * #{cv.getVar('avatars-spacing')}); + } + } + + // Non-overlapping: a positive gap of the same spacing var instead of a + // negative margin, and no ring (there is nothing to separate from). + &.#{iv.$class-prefix}is-spaced { + gap: cv.getVar('avatars-spacing'); + + .#{iv.$class-prefix}avatar { + box-shadow: none; + + &:not(:first-child) { + margin-left: 0; + } + } + } + + // The "+N" surplus avatar uses scheme-aware tokens so it flips in dark mode + // (a fixed light color would stay pale on a dark scheme). + .#{iv.$class-prefix}avatar.#{iv.$class-prefix}is-surplus { + background-color: cv.getVar('background'); + color: cv.getVar('text'); + } +} diff --git a/bulma-ui/src/scss/components/_badge.scss b/bulma-ui/src/scss/components/_badge.scss new file mode 100644 index 000000000..b3bd68b04 --- /dev/null +++ b/bulma-ui/src/scss/components/_badge.scss @@ -0,0 +1,183 @@ +// Badge component styles — a small status/count indicator overlaid on a corner +@use 'bulma/sass/utilities/initial-variables' as iv; +@use 'bulma/sass/utilities/css-variables' as cv; + +// Badge-specific SCSS variables +$badge-height: 1.25em !default; +$badge-min-width: 1.25em !default; +$badge-padding: 0 0.4em !default; +$badge-font-size: 0.7rem !default; +$badge-radius: cv.getVar('radius-rounded') !default; +$badge-ring-color: cv.getVar('scheme-main') !default; +$badge-ring-width: 2px !default; +$badge-dot-size: 0.65em !default; +$badge-inset-circle: 12% !default; +$badge-animation-duration: 1.4s !default; + +.#{iv.$class-prefix}badge-wrapper { + position: relative; + display: inline-flex; + line-height: 0; +} + +.#{iv.$class-prefix}badge { + @include cv.register-vars( + ( + 'badge-height': #{$badge-height}, + 'badge-min-width': #{$badge-min-width}, + 'badge-padding': #{$badge-padding}, + 'badge-font-size': #{$badge-font-size}, + 'badge-radius': #{$badge-radius}, + 'badge-ring-color': #{$badge-ring-color}, + 'badge-ring-width': #{$badge-ring-width}, + 'badge-dot-size': #{$badge-dot-size}, + 'badge-inset-circle': #{$badge-inset-circle}, + 'badge-animation-duration': #{$badge-animation-duration}, + ) + ); +} + +.#{iv.$class-prefix}badge { + position: absolute; + z-index: 1; + display: inline-flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + height: cv.getVar('badge-height'); + min-width: cv.getVar('badge-min-width'); + padding: cv.getVar('badge-padding'); + border-radius: cv.getVar('badge-radius'); + box-shadow: 0 0 0 cv.getVar('badge-ring-width') cv.getVar('badge-ring-color'); + font-size: cv.getVar('badge-font-size'); + line-height: 1; + white-space: nowrap; + pointer-events: none; + + &.#{iv.$class-prefix}is-dot { + height: cv.getVar('badge-dot-size'); + min-width: cv.getVar('badge-dot-size'); + padding: 0; + } + + &.#{iv.$class-prefix}is-invisible { + visibility: hidden; + } +} + +// Corner positions (relative to the .badge-wrapper, unless is-standalone) +.#{iv.$class-prefix}badge.#{iv.$class-prefix}is-top-right { + top: 0; + right: 0; + transform: translate(50%, -50%); +} + +.#{iv.$class-prefix}badge.#{iv.$class-prefix}is-top-left { + top: 0; + left: 0; + transform: translate(-50%, -50%); +} + +.#{iv.$class-prefix}badge.#{iv.$class-prefix}is-bottom-right { + bottom: 0; + right: 0; + transform: translate(50%, 50%); +} + +.#{iv.$class-prefix}badge.#{iv.$class-prefix}is-bottom-left { + bottom: 0; + left: 0; + transform: translate(-50%, 50%); +} + +// 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. +.#{iv.$class-prefix}badge.#{iv.$class-prefix}is-standalone { + position: static; + display: inline-flex; + transform: none; +} + +// Overlap adjustment — nudge the badge further in on a round child so it +// doesn't float off the visible edge. +.#{iv.$class-prefix}badge.#{iv.$class-prefix}is-overlap-circle.#{iv.$class-prefix}is-top-right { + top: cv.getVar('badge-inset-circle'); + right: cv.getVar('badge-inset-circle'); +} + +.#{iv.$class-prefix}badge.#{iv.$class-prefix}is-overlap-circle.#{iv.$class-prefix}is-top-left { + top: cv.getVar('badge-inset-circle'); + left: cv.getVar('badge-inset-circle'); +} + +.#{iv.$class-prefix}badge.#{iv.$class-prefix}is-overlap-circle.#{iv.$class-prefix}is-bottom-right { + bottom: cv.getVar('badge-inset-circle'); + right: cv.getVar('badge-inset-circle'); +} + +.#{iv.$class-prefix}badge.#{iv.$class-prefix}is-overlap-circle.#{iv.$class-prefix}is-bottom-left { + bottom: cv.getVar('badge-inset-circle'); + left: cv.getVar('badge-inset-circle'); +} + +// Color variants — background + a contrasting foreground (mirrors Toast/Avatar). +@each $name in 'primary', 'link', 'info', 'success', 'warning', 'danger' { + .#{iv.$class-prefix}badge.#{iv.$class-prefix}is-#{$name} { + background-color: cv.getVar($name); + color: hsl( + cv.getVar($name, '', '-h'), + cv.getVar($name, '', '-s'), + cv.getVar($name, '', '-invert-l') + ); + } +} + +.#{iv.$class-prefix}badge.#{iv.$class-prefix}is-black { + background-color: cv.getVar('black'); + color: cv.getVar('white'); +} + +.#{iv.$class-prefix}badge.#{iv.$class-prefix}is-dark { + background-color: cv.getVar('dark'); + color: cv.getVar('light'); +} + +.#{iv.$class-prefix}badge.#{iv.$class-prefix}is-light { + background-color: cv.getVar('light'); + color: cv.getVar('dark'); +} + +.#{iv.$class-prefix}badge.#{iv.$class-prefix}is-white { + background-color: cv.getVar('white'); + color: cv.getVar('black'); +} + +// Pulse animation for "processing" badges. +.#{iv.$class-prefix}badge.#{iv.$class-prefix}is-pulse::after { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + background-color: inherit; + animation: extras-badge-pulse cv.getVar('badge-animation-duration') + cubic-bezier(0, 0, 0.2, 1) infinite; +} + +@keyframes extras-badge-pulse { + 0% { + opacity: 0.6; + transform: scale(1); + } + 100% { + opacity: 0; + transform: scale(1.75); + } +} + +@media (prefers-reduced-motion: reduce) { + .#{iv.$class-prefix}badge.#{iv.$class-prefix}is-pulse::after { + animation: none; + display: none; + } +} diff --git a/bulma-ui/src/scss/components/_index.scss b/bulma-ui/src/scss/components/_index.scss index bf0bb39b0..ae004d03d 100644 --- a/bulma-ui/src/scss/components/_index.scss +++ b/bulma-ui/src/scss/components/_index.scss @@ -12,3 +12,6 @@ @use 'carousel'; @use 'tabs'; @use 'reveal'; +@use 'avatar'; +@use 'avatars'; +@use 'badge'; diff --git a/docs/docs/api/components/avatar.md b/docs/docs/api/components/avatar.md new file mode 100644 index 000000000..a50315d86 --- /dev/null +++ b/docs/docs/api/components/avatar.md @@ -0,0 +1,157 @@ +--- +title: Avatar +sidebar_label: Avatar +--- + +# Avatar + +## Overview + +The `Avatar` component represents a person or entity as a compact image. It falls back +automatically from a photo (`src`) to initials (from `name`/`initials`) to a custom `icon`, and +finally to a generic default icon — so you never have to hand-roll the broken-image or +missing-photo case. + +--- + +## Import + +```tsx +import { Avatar } from '@allxsmith/bestax-bulma'; +``` + +--- + +## Props + +| Prop | Type | Default | Description | +| ------------ | ---------------------------------------------------------------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `src` | `string` | — | Image URL. On load error (or if absent), falls back to initials, then `icon`. | +| `alt` | `string` | — | Alternate text for the image (used for the accessible name in every render mode). | +| `name` | `string` | — | Derives initials and a deterministic background color when no `src` is shown. | +| `initials` | `string` | — | Explicit initials override (else derived from `name`). | +| `icon` | `React.ReactNode` | — | Final fallback, rendered when there is no `src`, `name`, or `initials`. | +| `size` | `'16x16' \| '24x24' \| '32x32' \| '48x48' \| '64x64' \| '96x96' \| '128x128' \| number` | — | Preset size, or a pixel size when a number. | +| `shape` | `'circle' \| 'rounded' \| 'square'` | `'circle'` | Avatar shape. | +| `color` | `'primary' \| 'link' \| 'info' \| 'success' \| 'warning' \| 'danger' \| 'black' \| 'dark' \| 'light' \| 'white'` | — | Background color for initials/icon avatars (else auto-derived from `name`). | +| `as` | `React.ElementType` | — | Element/component to render as. Defaults to `'a'` when `href` is set, else `'figure'`. | +| `href` | `string` | — | When set, renders the avatar as a link. | +| `target` | `string` | — | Anchor target — forwarded only when rendering a link (an `a` or a custom `as` component). | +| `rel` | `string` | — | Anchor rel — forwarded only when rendering a link (an `a` or a custom `as` component). | +| `imageProps` | `React.ImgHTMLAttributes` | — | Extra props forwarded to the underlying `` (e.g. `loading`, `crossOrigin`); its `onError` is chained before the fallback fires. | +| `className` | `string` | — | Additional CSS classes. | +| ... | All standard HTML and Bulma helper props | | (See [Helper Props](../helpers/usebulmaclasses)) | + +--- + +## Usage + +### Photo with Automatic Fallback + +A working photo renders as an image; if the `src` fails to load, `Avatar` swaps to initials +derived from `name` automatically — no broken-image icon. + +```tsx live + + + + +``` + +### Initials + +With no `src`, initials render on a deterministic auto background color derived from `name`. + +```tsx live + + + + + +``` + +### Icon Fallback + +Pass an `icon` to control the final fallback when there is no photo, name, or initials. + +```tsx live +} color="info" shape="rounded" /> +``` + +### Shapes + +The `shape` prop switches between a circle, a rounded square, and a plain square. + +```tsx live + + + + + +``` + +### Sizes + +Preset sizes mirror `Image`'s fixed-size list; a `number` renders a pixel size. + +```tsx live + + + + + + + +``` + +### Clickable Avatar + +Set `href` to render the avatar as a link (or pass `as` for a custom element). + +```tsx live + +``` + +### Forwarding Props to the Image + +`imageProps` is spread onto the underlying `` — handy for native attributes like +`loading`, `crossOrigin`, or `referrerPolicy`. A custom `onError` is chained _before_ the +automatic initials/icon fallback runs. + +```tsx live + +``` + +--- + +## Accessibility + +- Image avatars use `alt` (falling back to `name`) for their accessible name. +- Initials/icon avatars expose `role="img"` and `aria-label` (from `alt`/`name`) — unless + rendered as a link or button, where the native link/button role and `aria-label` are used + instead. +- The default fallback icon is `aria-hidden`. + +--- + +## Related Components + +- [`Avatars`](./avatars.md): An overlapping group of `Avatar`s with a "+N" surplus bubble. +- [`Badge`](./badge.md): A status/count indicator that overlays an `Avatar` (or any element). +- [`Image`](../elements/image.md): Bulma's fixed-ratio image container. +- [Helper Props](../helpers/usebulmaclasses.md): Bulma helper props for spacing, color, etc. + +--- + +## Additional Resources + +- [Storybook: Avatar Stories](https://bestax.io/storybook/?path=/story/components-avatar--photo) diff --git a/docs/docs/api/components/avatars.md b/docs/docs/api/components/avatars.md new file mode 100644 index 000000000..18a24fed3 --- /dev/null +++ b/docs/docs/api/components/avatars.md @@ -0,0 +1,164 @@ +--- +title: Avatars +sidebar_label: Avatars +--- + +# Avatars + +## Overview + +The `Avatars` component renders an overlapping/stacked group of `Avatar`s, the "members" list pattern. + +Clamp the visible count with `max`; the overflow renders as a single `+N` surplus avatar. It +follows the library's sibling plural container convention ([`Tags`](../elements/tags), +[`Buttons`](../elements/buttons)). + +--- + +## Import + +```tsx +import { Avatars, Avatar } from '@allxsmith/bestax-bulma'; +``` + +--- + +## Props + +| Prop | Type | Default | Description | +| ----------- | ---------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `max` | `number` | — | Show only the first `max` children, replacing the overflow with a "+N" surplus avatar. A single overflow avatar is shown directly rather than as a pointless "+1". | +| `size` | `AvatarProps['size']` | — | Uniform size applied to every child `Avatar` (and the surplus avatar). | +| `shape` | `'circle' \| 'rounded' \| 'square'` | — | Uniform shape applied to every child `Avatar` (and the surplus avatar); a child's own `shape` wins when this is unset. | +| `spacing` | `'sm' \| 'md' \| 'lg' \| number` | `'md'` | Space between avatars: a preset or a pixel `number`. The overlap distance, or the gap when `spaced`. | +| `spaced` | `boolean` | `false` | Lay the avatars out side by side (non-overlapping), using `spacing` as the gap. | +| `className` | `string` | — | Additional CSS classes. | +| `children` | `React.ReactNode` | — | `Avatar` elements to render inside the group. | +| ... | All standard HTML and Bulma helper props | | (See [Helper Props](../helpers/usebulmaclasses)) | + +`Avatar` is also attached as a compound static (`Avatars.Avatar`), so you can import just the +container. + +--- + +## Usage + +### Default + +Children overlap left-to-right, each ringed against the one beneath it. + +```tsx live + + + + + +``` + +### Clamped with a Surplus Bubble + +Set `max` to cap the visible avatars; the remainder collapse into a single `+N` surplus bubble. + +```tsx live + + + + + + + +``` + +### Uniform Shape + +`shape` applies to every child (and the surplus avatar); a child that sets its own `shape` keeps it. + +```tsx live + + + + + + + +``` + +### Compound Static + +`Avatars.Avatar` is the same component as `Avatar`, handy when you import only the container. + +```tsx live + + + + + +``` + +### Spacing + +A `'sm' | 'md' | 'lg'` preset controls how far the avatars overlap; each group is stacked here +so they read as separate rows. + +```tsx live + + + + + + + + + + + + +``` + +A `number` sets a pixel overlap directly: + +```tsx live + + + + + +``` + +### Non-overlapping (Spaced) + +`spaced` lays the avatars out side by side, using `spacing` as the gap instead of an overlap. + +```tsx live + + + + + +``` + +--- + +## Accessibility + +- `Avatars` is a plain container — each child `Avatar` carries its own accessible name (see + [Avatar's accessibility notes](./avatar.md#accessibility)). +- The `+N` surplus avatar gets an accessible name of `"{N} more"` (via its `alt`), so assistive + tech announces how many members are hidden. +- When the group represents a labelled set (e.g. "Project members"), give the container a + `role="group"` and an `aria-label` describing it. + +--- + +## Related Components + +- [`Avatar`](./avatar.md): The individual avatar this component groups. +- [`Badge`](./badge.md): A status/count indicator overlay. +- [`Tags`](../elements/tags.md): The same sibling-plural-container convention for tags. +- [Helper Props](../helpers/usebulmaclasses.md): Bulma helper props for spacing, color, etc. + +--- + +## Additional Resources + +- [Storybook: Avatars Stories](https://bestax.io/storybook/?path=/story/components-avatars--default) diff --git a/docs/docs/api/components/badge.md b/docs/docs/api/components/badge.md new file mode 100644 index 000000000..ce85b2fa2 --- /dev/null +++ b/docs/docs/api/components/badge.md @@ -0,0 +1,244 @@ +--- +title: Badge +sidebar_label: Badge +--- + +# Badge + +## Overview + +The `Badge` component is a small status/count indicator overlaid on the corner of another element, or rendered standalone. + +Use it for online-status dots, unread counts, or "pending" bubbles — [`Tag`](../elements/tag) is +an inline label; `Badge` is a positioned overlay. + +--- + +## Import + +```tsx +import { Badge } from '@allxsmith/bestax-bulma'; +``` + +--- + +## Props + +| 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}+"`. | +| `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. | +| `position` | `'top-right' \| 'top-left' \| 'bottom-right' \| 'bottom-left'` | `'top-right'` | Corner to overlay the badge on, relative to `children`. Ignored for standalone badges (no `children`). | +| `overlap` | `'circle' \| 'square'` | `'square'` | Nudges the offset for a round (`'circle'`) vs rectangular (`'square'`) child. Ignored for standalone badges (no `children`). | +| `pulse` | `boolean` | `false` | Processing/pulse animation; no-ops under `prefers-reduced-motion: reduce`. | +| `invisible` | `boolean` | `false` | Hide the badge pill without unmounting it (the wrapper and `children` stay visible). | +| `className` | `string` | — | Additional CSS classes for the root — the wrapper when `children` are present, else the badge pill. | +| `badgeClassName` | `string` | — | Additional CSS classes applied to the badge pill itself (unprefixed, like `Tooltip`'s `tooltipClassName`). | +| `children` | `React.ReactNode` | — | The element the badge overlays. Omit to render a standalone badge. | +| ... | All standard HTML and Bulma helper props | | (See [Helper Props](../helpers/usebulmaclasses)) | + +--- + +## Usage + +### Status Dot on an Avatar + +A `dot` badge drops the content and renders a small indicator — pair it with `overlap="circle"` +so it hugs a round avatar. + +```tsx live + + + +``` + +### Unread Count + +Numeric `content` above `max` renders as `"{max}+"`. + +```tsx live + + + +``` + +### Unread Mail + +The classic envelope-with-count pattern; give the icon anchor an `ariaLabel` describing the whole +control. + +```tsx live + + + +``` + +### Badge on a Button + +Wrap any element — here a cart button carries its item count. + +```tsx live + + + +``` + +### Positions + +Each badge is translated outside its anchor, so a flex row with a gap keeps neighbors from +colliding. + +```tsx live + + + + + + + + + + + + + + +``` + +### Overlap: Circle vs Square + +`overlap` nudges the offset inward for a round child so the badge doesn't float off the edge. + +```tsx live + + + + + + + + +``` + +### Colors + +`color` accepts any Bulma color for the pill background. + +```tsx live + + + + + + + + + + + + + + + + + +``` + +### Pulse + +`pulse` adds a "processing" ripple; it no-ops under `prefers-reduced-motion: reduce`. + +```tsx live + + + +``` + +### Toggling Visibility + +The `invisible` prop hides just the pill while keeping the anchor in place — toggle it with state. + +```tsx live +function example() { + const [muted, setMuted] = useState(false); + + return ( + + + + + + + ); +} +``` + +### Hidden at Zero (unless `showZero`) + +A numeric `content` of `0` hides the badge by default; pass `showZero` to keep it. + +```tsx live + + + + + + + + +``` + +### Standalone + +Omit `children` to render the pill inline in normal flow; `position`/`overlap` are ignored. + +```tsx live + +``` + +### Custom Node Content + +`content` accepts any `React.ReactNode` — pass an icon or element instead of a count. `max` and +`showZero` are ignored for non-numeric content. + +```tsx live +} color="success"> + + +``` + +--- + +## Accessibility + +- A count/text badge exposes `role="status"` and an `aria-label` announcing its content. +- A bare number in a `role="status"` span is not enough context on its own — put the full + meaning on the anchor itself, e.g. `` rather than + relying on the badge to read out just "4". +- A decorative `dot` badge is `aria-hidden`. +- `pulse` respects `prefers-reduced-motion: reduce` and renders without animation. +- Note the two hide mechanisms differ: the Bulma `visibility="invisible"` helper hides the whole + wrapper (anchor included), while the `invisible` **prop** hides only the badge pill. + +--- + +## Related Components + +- [`Avatar`](./avatar.md): A common element to overlay a `Badge` on. +- [`Tag`](../elements/tag.md): An inline label, versus `Badge`'s positioned overlay. +- [`Icon`](../elements/icon.md): The icon anchors used throughout these examples. +- [Helper Props](../helpers/usebulmaclasses.md): Bulma helper props for spacing, color, etc. + +--- + +## Additional Resources + +- [Storybook: Badge Stories](https://bestax.io/storybook/?path=/story/components-badge--unread-count) diff --git a/docs/docs/guides/library/components.md b/docs/docs/guides/library/components.md index 1fde53b53..b6bef75e7 100644 --- a/docs/docs/guides/library/components.md +++ b/docs/docs/guides/library/components.md @@ -321,6 +321,38 @@ Expandable/collapsible content panels with smooth animation. Works in controlled --- +## Media + +### Avatar + +A person/entity image primitive that falls back to initials, then an icon, when no image is available. Supports sizes, shapes, and rendering as a link. [View full documentation.](../../api/components/avatar.md) + +```tsx live + + + + + +``` + +--- + +### Avatars + +An overlapping (or `spaced`) group of `Avatar`s that clamps to `max` and renders the overflow as a single `+N` surplus avatar. [View full documentation.](../../api/components/avatars.md) + +```tsx live + + + + + + + +``` + +--- + ## Overlays ### Modal @@ -510,6 +542,26 @@ A versatile dropdown menu for navigation, actions, or pop-up lists. [View full d ## Feedback +### Badge + +A small status/count indicator overlaid on a corner of its `children` (or standalone). Renders `"{max}+"` past a threshold, hides at `0` unless `showZero`, and supports a plain `dot` mode. [View full documentation.](../../api/components/badge.md) + +```tsx live + + + + + + + + + + + +``` + +--- + ### Message A Bulma-styled message box for feedback, alerts, or notifications. [View full documentation.](../../api/components/message.md) diff --git a/docs/src/components/EnhancedAddons/icons.js b/docs/src/components/EnhancedAddons/icons.js index f1fbe23f6..77941196e 100644 --- a/docs/src/components/EnhancedAddons/icons.js +++ b/docs/src/components/EnhancedAddons/icons.js @@ -20,6 +20,26 @@ function LinkButtonIcon() { ); } +// Person in a frame — avatar +function AvatarIcon() { + return ( + + + + + ); +} + +// Corner count indicator — badge +function BadgeIcon() { + return ( + + + + + ); +} + // Sliding frames — carousel function CarouselIcon() { return ( @@ -106,6 +126,8 @@ function TooltipIcon() { const ADDON_ICONS = { linkbutton: LinkButtonIcon, + avatar: AvatarIcon, + badge: BadgeIcon, carousel: CarouselIcon, collapse: CollapseIcon, dialog: DialogIcon, diff --git a/docs/src/components/EnhancedAddons/index.js b/docs/src/components/EnhancedAddons/index.js index 33aa66399..edb953584 100644 --- a/docs/src/components/EnhancedAddons/index.js +++ b/docs/src/components/EnhancedAddons/index.js @@ -12,6 +12,18 @@ const ADDONS = [ link: '/docs/api/elements/linkbutton', desc: 'A button that dresses like a link — semantics of a button, look of an anchor.', }, + { + name: 'Avatar', + icon: 'avatar', + link: '/docs/api/components/avatar', + desc: 'User images with automatic initials and icon fallbacks, in any size or shape.', + }, + { + name: 'Badge', + icon: 'badge', + link: '/docs/api/components/badge', + desc: 'Corner status dots and counts overlaid on any element.', + }, { name: 'Carousel', icon: 'carousel', diff --git a/skills/bestax-custom-component/references/component-catalog.md b/skills/bestax-custom-component/references/component-catalog.md index 2466d7a76..612335a77 100644 --- a/skills/bestax-custom-component/references/component-catalog.md +++ b/skills/bestax-custom-component/references/component-catalog.md @@ -19,7 +19,7 @@ instead of hand-writing markup. - Raw `*Base` form exports (`InputBase`, `SelectBase`, `TextAreaBase`, …) are escape-hatch variants of the convenience wrappers above them; see the Form docs. -82 documented components. Generated from the API docs — every exported +85 documented components. Generated from the API docs — every exported component is guaranteed to appear (the generator fails if one lacks an API page). ## Elements @@ -57,6 +57,9 @@ component is guaranteed to appear (the generator fails if one lacks an API page) ## Components +- [Avatar](https://bestax.io/docs/api/components/avatar) — The `Avatar` component represents a person or entity as a compact image. +- [Avatars](https://bestax.io/docs/api/components/avatars) — The `Avatars` component renders an overlapping/stacked group of `Avatar`s, the "members" list pattern. +- [Badge](https://bestax.io/docs/api/components/badge) — The `Badge` component is a small status/count indicator overlaid on the corner of another element, or rendered standalone. - [Breadcrumb](https://bestax.io/docs/api/components/breadcrumb) — The `Breadcrumb` component renders a Bulma-styled breadcrumb navigation. - [Card](https://bestax.io/docs/api/components/card) — The `Card` component renders a Bulma-styled card with optional header, image, content, and footer. - [Carousel](https://bestax.io/docs/api/components/carousel) — The `Carousel` component provides an image/content slider with navigation arrows and indicators. diff --git a/skills/bestax-theming/references/css-variables.md b/skills/bestax-theming/references/css-variables.md index 19b4af24c..3258c5971 100644 --- a/skills/bestax-theming/references/css-variables.md +++ b/skills/bestax-theming/references/css-variables.md @@ -96,6 +96,28 @@ and numeric shades `--bulma--00` … `--bulma--95`. | `--bulma-size-small` / `-normal` / `-medium` / `-large` | 0.75 / 1 / 1.25 / 1.5rem | via `bulmaVars` | | `--bulma-weight-light/normal/medium/semibold/bold/extrabold` | 300 / 400 / 500 / 600 / 700 / 800 | via `bulmaVars` | +## Extras component variables (Avatar / Avatars / Badge) + +These are registered on the component's own selector (`.avatar`, `.avatars`, `.badge`), not on +`:root`, so override them with a scoped rule, the component's `style`/`className`, or `Theme`'s +`bulmaVars` on a wrapping `Theme`. Several default to core theme vars above, so they already flow +through a custom theme. + +| Variable | Default | +| ----------------------------------------------------------------- | -------------------------------- | +| `--bulma-avatar-size` | `48px` | +| `--bulma-avatar-background` / `--bulma-avatar-color` | `background` / `text` | +| `--bulma-avatar-weight` | `weight-semibold` | +| `--bulma-avatar-rounded-radius` | `radius-large` | +| `--bulma-avatars-ring-color` / `--bulma-avatars-ring-width` | `scheme-main` / `2px` | +| `--bulma-avatars-spacing` | `0.75rem` (`sm` 0.5 / `lg` 1rem) | +| `--bulma-badge-height` / `--bulma-badge-min-width` | `1.25em` / `1.25em` | +| `--bulma-badge-padding` / `--bulma-badge-font-size` | `0 0.4em` / `0.7rem` | +| `--bulma-badge-radius` | `radius-rounded` | +| `--bulma-badge-ring-color` / `--bulma-badge-ring-width` | `scheme-main` / `2px` | +| `--bulma-badge-dot-size` | `0.65em` | +| `--bulma-badge-inset-circle` / `--bulma-badge-animation-duration` | `12%` / `1.4s` | + ## Dark mode (`Theme colorMode`) Drive the light/dark scheme with the `Theme` component's **`colorMode`** prop — diff --git a/skills/bestax-theming/references/themeable-components.md b/skills/bestax-theming/references/themeable-components.md index afcff31b5..d791abe4d 100644 --- a/skills/bestax-theming/references/themeable-components.md +++ b/skills/bestax-theming/references/themeable-components.md @@ -28,16 +28,18 @@ Shades (`colorShade` / `backgroundColorShade`): `00, 05, 10, … 95, invert, lig ## Component `color` / `size` props (verbatim unions) -| Component | `color` accepts | `size` accepts | Notes | -| -------------- | ------------------------------------------------------------------------------------------------------------- | ------------------------------------ | ----------------------------------------------------------------------------- | -| `Button` | `primary \| link \| info \| success \| warning \| danger \| white \| light \| dark \| black \| text \| ghost` | `small \| normal \| medium \| large` | adds `text`, `ghost`; also `isLight`, `isOutlined`, `isInverted`, `isRounded` | -| `Notification` | the 17 `validColors` | — | also `isLight` | -| `Tag` | `primary \| link \| info \| success \| warning \| danger \| black \| dark \| light \| white` | `normal \| medium \| large` | also `isRounded`, `isDelete`, `isHoverable` | -| `Box` | `primary \| link \| info \| success \| warning \| danger` | — | the 6 only; also `hasShadow` | -| `Message` | `primary \| link \| info \| success \| warning \| danger` | — | the 6 only | -| `Input` | `primary \| link \| info \| success \| warning \| danger \| black \| dark \| light \| white` | `small \| medium \| large` | also `isRounded`, `isStatic` | -| `Title` | — (no `color`; use `textColor`) | `1 \| 2 \| 3 \| 4 \| 5 \| 6` | also `isSpaced` | -| `SubTitle` | — (no `color`; use `textColor`) | `1 \| 2 \| 3 \| 4 \| 5 \| 6` | — | +| Component | `color` accepts | `size` accepts | Notes | +| -------------- | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | +| `Button` | `primary \| link \| info \| success \| warning \| danger \| white \| light \| dark \| black \| text \| ghost` | `small \| normal \| medium \| large` | adds `text`, `ghost`; also `isLight`, `isOutlined`, `isInverted`, `isRounded` | +| `Notification` | the 17 `validColors` | — | also `isLight` | +| `Tag` | `primary \| link \| info \| success \| warning \| danger \| black \| dark \| light \| white` | `normal \| medium \| large` | also `isRounded`, `isDelete`, `isHoverable` | +| `Box` | `primary \| link \| info \| success \| warning \| danger` | — | the 6 only; also `hasShadow` | +| `Message` | `primary \| link \| info \| success \| warning \| danger` | — | the 6 only | +| `Input` | `primary \| link \| info \| success \| warning \| danger \| black \| dark \| light \| white` | `small \| medium \| large` | also `isRounded`, `isStatic` | +| `Avatar` | `primary \| link \| info \| success \| warning \| danger \| black \| dark \| light \| white` | `16x16 \| 24x24 \| 32x32 \| 48x48 \| 64x64 \| 96x96 \| 128x128 \| number` | initials/icon background (auto-derived from `name` when unset); also `shape` | +| `Badge` | `primary \| link \| info \| success \| warning \| danger \| black \| dark \| light \| white` | — | pill background; default `danger` | +| `Title` | — (no `color`; use `textColor`) | `1 \| 2 \| 3 \| 4 \| 5 \| 6` | also `isSpaced` | +| `SubTitle` | — (no `color`; use `textColor`) | `1 \| 2 \| 3 \| 4 \| 5 \| 6` | — | The 6 brand colors (`primary, link, info, success, warning, danger`) are the ones a custom theme recolors via the HSL trios (see `css-variables.md`). The greyscale and `white`/`light`/`dark`