diff --git a/src/components/atoms/chip/Chip.stories.tsx b/src/components/atoms/chip/Chip.stories.tsx new file mode 100644 index 00000000..bee834d8 --- /dev/null +++ b/src/components/atoms/chip/Chip.stories.tsx @@ -0,0 +1,291 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Check, Trash2 } from 'lucide-react'; +import React, { useState } from 'react'; +import Avatar from '../avatar/Avatar'; +import IconButton from '../icon-button'; +import Icon from '../icon/Icon'; +import { Chip } from './Chip'; + +/** + * ## DESCRIPTION + * Chip component is a compact element used to display statuses, keywords, or quick actions. + * + * Common use cases include tags, filters, and state indicators in dense interfaces. + * + * - Customizable in color, size, variant, radius and animation. + * - Supports `startContent` / `endContent` (icons or text), optional avatar, and `dot` indicator. + * - Optional interactivity: clickable (`as="button"`), selectable (controlled or uncontrolled), and closable. + * - Accessible via the `ariaLabel` prop when using `variant="dot"` without text. + */ + +const meta: Meta = { + title: 'Atoms/Chip', + component: Chip, + parameters: { + docs: { autodocs: true } + }, + tags: ['autodocs'], + argTypes: { + color: { control: 'select', options: ['primary', 'secondary', 'success', 'warning', 'danger'] }, + size: { control: 'select', options: ['sm', 'md', 'lg'] }, + variant: { control: 'select', options: ['solid', 'bordered', 'light', 'flat', 'faded', 'shadow', 'dot'] }, + radius: { control: 'select', options: ['none', 'sm', 'md', 'lg', 'full'] }, + animation: { control: 'select', options: ['default', 'pulse', 'bounce', 'ping'] }, + as: { control: 'select', options: ['div', 'button'] }, + selectable: { control: 'boolean' }, + selected: { control: 'boolean' }, + defaultSelected: { control: 'boolean' }, + closable: { control: 'boolean' }, + onClose: { action: 'onClose' }, + onSelectedChange: { action: 'onSelectedChange' } + }, + args: { + onClick: undefined + } +}; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + children: 'Chip', + color: 'primary', + variant: 'solid', + size: 'md', + animation: 'default', + as: 'div', + selectable: false, + closable: false, + onClick: undefined + }, + parameters: { + actions: { disable: true } + }, + argTypes: { + onClick: { table: { disable: true }, control: false } + } +}; + +/** + * The `size` prop adjusts height, horizontal padding and font-size of the chip. + * + * Available options: + * - `sm` → Small + * - `md` → Medium (default) + * - `lg` → Large + */ +export const Size: Story = { + args: { variant: 'light' }, + render: () => ( +
+ Small + Medium + Large +
+ ) +}; + +/** + * The `color` prop sets background and text color. + */ +export const Color: Story = { + render: () => ( +
+ Primary + Secondary + Success + Warning + Danger +
+ ) +}; + +/** + * The `variant` prop defines visual style modifications for the chip. + */ +export const Variant: Story = { + render: () => ( +
+ Solid + Flat + Shadow + Bordered + Light + Faded + + With dot + +
+ ) +}; + +/** + * The `radius` prop controls the corner roundness. + */ +export const Radius: Story = { + render: () => ( +
+ none + sm + md + lg + full +
+ ) +}; + +/** + * `startContent` and `endContent` allow placing icons or text before/after the chip label. + */ +export const StartEndContent: Story = { + args: { + children: 'Status', + color: 'primary', + startContent: , + endContent: ( + + + + ) + } +}; + +/** + * Make chips clickable by setting `as="button"` and providing `onClick`. + */ +export const Clickable: Story = { + args: { + children: 'Clickable', + as: 'button' + }, + argTypes: { + onClick: { action: 'onClick' } + } +}; + +/** + * Closable chips can be removed from a list. + */ +export const ClosableList = () => { + const [items, setItems] = useState(['React', 'NextJS', 'Tailwind']); + + return ( +
+ {items.map((label, idx) => ( + setItems(items.filter((_, i) => i !== idx))}> + {label} + + ))} +
+ ); +}; + +/** + * Chips can be selectable. Use `defaultSelected` for uncontrolled usage, + * or `selected` + `onSelectedChange` for controlled state. + */ +export const SelectableUncontrolled: Story = { + args: { + children: 'Toggle me', + selectable: true, + defaultSelected: false + } +}; + +export const SelectableControlled: Story = { + render: (args) => { + const [sel, setSel] = React.useState(true); + return ( + : null} + > + {sel ? 'Selected' : 'Not selected'} + + ); + } +}; + +/** Avatar al inicio (usa tu componente Avatar) */ +export const WithAvatar: Story = { + render: () => ( +
+ {/* Forzamos el slot avatar a 16px y recortamos */} + }>EGDEV + + } + > + User + + + A
} + > + Andrés + + + ) +}; + +/** With text → shows a circular indicator before the label. */ +export const DotWithText: Story = { + args: { variant: 'dot', color: 'primary', children: 'Pending' } +}; + +/** Dot only → provide `ariaLabel` for accessibility. */ +export const DotOnlyAccessible: Story = { + args: { variant: 'dot', color: 'primary', ariaLabel: 'Online' } +}; + +/** You can override slot styles with `classNames`. */ +export const WithClassNamesOverrides: Story = { + args: { + children: 'Custom Slots', + classNames: { + base: 'bg-blue-700 text-white hover:bg-blue-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-blue-800', + content: 'tracking-wide', + closeButton: 'bg-white/10 hover:bg-white/20' + }, + closable: true, + animation: 'bounce', + as: 'div', + onClick: undefined + }, + parameters: { + actions: { disable: true } + }, + argTypes: { + onClick: { table: { disable: true }, control: false } + } +}; + +/** Stress test for long labels */ +export const Stress: Story = { + render: () => ( +
+ } + endContent={} + > + Truncated: Very very very long label that should nicely + +
+ ) +}; diff --git a/src/components/atoms/chip/Chip.tsx b/src/components/atoms/chip/Chip.tsx new file mode 100644 index 00000000..7745115d --- /dev/null +++ b/src/components/atoms/chip/Chip.tsx @@ -0,0 +1,52 @@ +import * as React from 'react'; +import type { ChipProps } from './types'; +import { useChip } from './useChip'; + +export const Chip = React.forwardRef((props, ref) => { + const { Tag, slots, isDot, hasChildren, propsBase, pieces, closable, handleClose } = useChip(props); + const { avatar, startContent, endContent, children } = pieces; + + return ( + + {avatar && ( + *]:origin-center [&>img]:h-full [&>img]:w-full [&>img]:object-cover [&>svg]:h-full [&>svg]:w-full', + '[&>*]:scale-[var(--avatar-scale,1)]' + ].join(' ')} + > + {avatar} + + )} + + {startContent && ( + + {startContent} + + )} + + {isDot && + ); +}); + +Chip.displayName = 'Chip'; diff --git a/src/components/atoms/chip/index.ts b/src/components/atoms/chip/index.ts new file mode 100644 index 00000000..f43e952f --- /dev/null +++ b/src/components/atoms/chip/index.ts @@ -0,0 +1,3 @@ +import { Chip } from './Chip'; +export * from './types' +export default Chip; \ No newline at end of file diff --git a/src/components/atoms/chip/types.ts b/src/components/atoms/chip/types.ts new file mode 100644 index 00000000..4a320397 --- /dev/null +++ b/src/components/atoms/chip/types.ts @@ -0,0 +1,200 @@ +import { type VariantProps, cva } from 'class-variance-authority'; +import type * as React from 'react'; + +export const chipVariants = cva( + [ + 'chip relative max-w-full min-w-0', + 'transition-all duration-200 ease-in-out', + 'flex items-center justify-center', + 'font-secondary-bold whitespace-nowrap leading-[1.2]', + 'disabled:pointer-events-none disabled:opacity-60', + 'focus-visible:outline-none', + 'data-[interactive=true]:focus-visible:ring-2 data-[interactive=true]:focus-visible:ring-[var(--color-accent)]', + 'dark:data-[interactive=true]:focus-visible:ring-[var(--color-text-dark)]', + 'data-[interactive=true]:focus-visible:ring-offset-2 data-[interactive=true]:focus-visible:ring-offset-[var(--surface-bg,white)]' + ], + { + variants: { + color: { primary: '', secondary: '', success: '', warning: '', danger: '' }, + + // ⬇️ Añadimos la custom prop --chip-h para poder usarla en el wrapper del avatar + size: { + sm: [ + 'h-4 px-1 gap-1 fs-small tablet:fs-small-tablet', + '[--chip-h:theme(spacing.4)]' // 16px + ].join(' '), + md: [ + 'h-6 px-2 gap-1 fs-base tablet:fs-base-tablet', + '[--chip-h:theme(spacing.6)]' // 24px + ].join(' '), + lg: [ + 'h-7 px-3 gap-2 fs-h6 tablet:fs-h6-tablet', + '[--chip-h:theme(spacing.7)]' // 28px + ].join(' ') + }, + + radiusSize: { none: '', sm: 'rounded-sm', md: 'rounded-md', lg: 'rounded-lg', full: 'rounded-full' }, + + variant: { + solid: 'border border-transparent', + light: 'bg-transparent border border-transparent', + flat: 'border border-transparent', + faded: 'border', + bordered: 'bg-transparent border', + shadow: 'border border-transparent', + dot: 'bg-transparent border' + }, + + startContent: { default: '', icon: 'mr-1', text: 'font-semibold' }, + endContent: { default: '', icon: 'ml-1', text: 'font-semibold' }, + + animation: { default: '', pulse: 'animate-pulse', bounce: 'animate-bounce', ping: 'animate-badgePing' } + }, + + compoundVariants: [ + /* ----------------- PRIMARY ----------------- */ + { + color: 'primary', + variant: 'solid', + class: [ + 'bg-[var(--color-primary)] text-[var(--color-text-dark)]', + 'data-[interactive=true]:hover:bg-[var(--color-red-600)] dark:data-[interactive=true]:hover:bg-[var(--color-red-700)]', + 'data-[interactive=true]:active:translate-y-[0.5px]' + ].join(' ') + }, + { + color: 'primary', + variant: 'light', + class: + 'text-[var(--color-primary)] data-[interactive=true]:hover:bg-[var(--color-white)] dark:data-[interactive=true]:hover:bg-[var(--color-red-200)]' + }, + { + color: 'primary', + variant: 'flat', + class: + 'bg-[var(--color-red-100)] text-[var(--color-primary)] data-[interactive=true]:hover:bg-[var(--color-red-200)]' + }, + { + color: 'primary', + variant: 'faded', + class: [ + 'bg-[var(--color-gray-dark-200)] border-[var(--color-gray-light-300)]', + 'text-[var(--color-primary)] data-[interactive=true]:hover:bg-[var(--color-gray-light-200)]', + 'dark:bg-[var(--color-gray-dark-700)] dark:border-[var(--color-gray-dark-600)] dark:text-[var(--color-accent)]', + 'dark:data-[interactive=true]:hover:bg-[var(--color-gray-dark-600)]' + ].join(' ') + }, + { + color: 'primary', + variant: 'bordered', + class: + 'text-[var(--color-primary)] border-[var(--color-primary)] data-[interactive=true]:hover:bg-[var(--color-red-100)] dark:data-[interactive=true]:hover:bg-[var(--color-gray-dark-700)]' + }, + { + color: 'primary', + variant: 'shadow', + class: [ + 'bg-[var(--color-primary)] text-[var(--color-text-dark)] border border-transparent', + 'shadow-none', + 'drop-shadow-[0_10px_10px_color-mix(in_srgb,var(--color-primary)_60%,transparent)]', + 'dark:drop-shadow-[0_10px_10px_color-mix(in_srgb,var(--color-primary)_80%,transparent)]', + 'shadow-[0_1px_0_rgba(0,0,0,.04),0_4px_10px_color-mix(in_srgb,var(--chip-shadow)_34%,transparent),0_12px_22px_color-mix(in_srgb,var(--chip-shadow)_22%,transparent)]', + 'data-[interactive=true]:hover:shadow-[0_2px_0_rgba(0,0,0,.04),0_6px_14px_color-mix(in_srgb,var(--chip-shadow)_40%,transparent),0_16px_30px_color-mix(in_srgb,var(--chip-shadow)_28%,transparent)]', + 'dark:shadow-[0_1px_0_rgba(255,255,255,.05),0_4px_10px_color-mix(in_srgb,var(--chip-shadow)_26%,transparent),0_12px_24px_color-mix(in_srgb,var(--chip-shadow)_18%,transparent)]', + 'data-[interactive=true]:active:translate-y-[0.5px]' + ].join(' ') + }, + { color: 'primary', variant: 'dot', class: '[--chip-dot:var(--color-primary)]' }, + + /* ----------------- SECONDARY ----------------- */ + { + color: 'secondary', + variant: 'solid', + class: [ + 'bg-[var(--color-gray-light-900)] text-[var(--color-text-dark)]', + 'data-[interactive=true]:hover:bg-[var(--color-gray-light-800)]', + 'dark:bg-[var(--color-gray-dark-200)] dark:text-[var(--color-text-light)] dark:data-[interactive=true]:hover:bg-[var(--color-gray-dark-300)]' + ].join(' ') + }, + { + color: 'secondary', + variant: 'light', + class: + 'text-[var(--color-text-light)] data-[interactive=true]:hover:bg-[var(--color-gray-light-200)] dark:text-[var(--color-text-dark)] dark:data-[interactive=true]:hover:bg-[var(--color-gray-dark-700)]' + }, + { + color: 'secondary', + variant: 'flat', + class: + 'bg-[var(--color-gray-light-200)] text-[var(--color-text-light)] data-[interactive=true]:hover:bg-[var(--color-gray-light-300)] dark:bg-[var(--color-gray-dark-800)] dark:text-[var(--color-text-dark)] dark:data-[interactive=true]:hover:bg-[var(--color-gray-dark-700)]' + }, + { + color: 'secondary', + variant: 'faded', + class: + 'bg-[var(--color-gray-light-100)] text-[var(--color-text-light)] border-[var(--color-gray-light-300)] data-[interactive=true]:hover:bg-[var(--color-gray-light-200)] dark:bg-[var(--color-gray-dark-700)] dark:text-[var(--color-text-dark)] dark:border-[var(--color-gray-dark-600)]' + }, + { + color: 'secondary', + variant: 'bordered', + class: + 'text-[var(--color-text-light)] border-[var(--color-gray-light-400)] data-[interactive=true]:hover:bg-[var(--color-gray-light-200)] dark:text-[var(--color-text-dark)] dark:border-[var(--color-gray-dark-400)] dark:data-[interactive=true]:hover:bg-[var(--color-gray-dark-700)]' + }, + { + color: 'secondary', + variant: 'shadow', + class: + 'bg-[var(--color-gray-light-900)] text-[var(--color-text-dark)] shadow-[0_10px_22px_-6px_rgba(0,0,0,.35),0_6px_14px_rgba(0,0,0,.25)] data-[interactive=true]:hover:shadow-[0_14px_26px_-8px_rgba(0,0,0,.45),0_10px_20px_rgba(0,0,0,.30)]' + }, + { + color: 'secondary', + variant: 'dot', + class: '[--chip-dot:var(--color-gray-light-900)] dark:[--chip-dot:var(--color-gray-dark-200)]' + } + + /* ----------------- SUCCESS / WARNING / DANGER ... (igual que tenías) */ + // ... + ], + + defaultVariants: { + color: 'primary', + size: 'md', + radiusSize: 'full', + variant: 'solid', + startContent: 'default', + endContent: 'default', + animation: 'default' + } + } +); + +export type RadiusSize = 'none' | 'sm' | 'md' | 'lg' | 'full'; +export type Animation = 'default' | 'pulse' | 'bounce' | 'ping'; + +export type ChipVariant = VariantProps['variant']; +export type ChipColorVariants = VariantProps['color']; +export type ChipSizeVariants = VariantProps['size']; + +export type ChipProps = { + children?: React.ReactNode; + variant?: ChipVariant; + color?: ChipColorVariants; + size?: ChipSizeVariants; + radius?: RadiusSize; + animation?: Animation; + avatar?: React.ReactNode; + startContent?: React.ReactNode; + endContent?: React.ReactNode; + as?: 'div' | 'button'; + onClick?: React.MouseEventHandler; + isDisabled?: boolean; + closable?: boolean; + onClose?: () => void; + selectable?: boolean; + selected?: boolean; + defaultSelected?: boolean; + onSelectedChange?: (selected: boolean) => void; + className?: string; + classNames?: Partial>; + ariaLabel?: string; +}; diff --git a/src/components/atoms/chip/useChip.ts b/src/components/atoms/chip/useChip.ts new file mode 100644 index 00000000..21fa0ead --- /dev/null +++ b/src/components/atoms/chip/useChip.ts @@ -0,0 +1,184 @@ +import clsx from 'clsx'; +import * as React from 'react'; +import { twMerge } from 'tailwind-merge'; +import { chipVariants } from './types'; +import type { ChipProps } from './types'; + +const cn = (...v: any[]) => twMerge(clsx(v)); +const isText = (n: React.ReactNode) => typeof n === 'string' || typeof n === 'number'; + +export function useChip(props: ChipProps) { + const { + variant = 'solid', + color = 'primary', + size = 'md', + radius, + animation = 'default', + startContent, + endContent, + children, + avatar, + className, + classNames, + isDisabled, + onClick, + as, + selectable, + selected, + defaultSelected, + onSelectedChange, + closable, + onClose, + ariaLabel, + ...rest + } = props; + + const isControlled = typeof selected === 'boolean'; + const [innerSelected, setInnerSelected] = React.useState(!!defaultSelected); + const isSelected = isControlled ? !!selected : innerSelected; + + const setSelected = (next: boolean) => { + if (!isControlled) { + setInnerSelected(next); + } + onSelectedChange?.(next); + }; + + const startKind = startContent == null ? 'default' : isText(startContent) ? 'text' : 'icon'; + const endKind = endContent == null ? 'default' : isText(endContent) ? 'text' : 'icon'; + + const interactive = !!onClick || !!selectable; + const Tag: 'div' | 'button' = as ?? (interactive ? 'button' : 'div'); + + const baseClasses = chipVariants({ + variant, + color, + size, + radiusSize: radius, + startContent: startKind, + endContent: endKind, + animation + }); + + const hasText = (n: React.ReactNode) => !(n === null || n === undefined || (typeof n === 'string' && n.length === 0)); + + const isDot = variant === 'dot'; + const hasChildren = hasText(children); + const hasStart = !!startContent || !!avatar; + const hasEnd = !!endContent; + + const pieceCount = + (hasStart ? 1 : 0) + (hasEnd ? 1 : 0) + (hasChildren ? 1 : 0) + (isDot ? 1 : 0) + (closable ? 1 : 0); + + const iconBySize = + size === 'sm' + ? '[&_svg]:h-3.5 [&_svg]:w-3.5' + : size === 'lg' + ? '[&_svg]:h-4.5 [&_svg]:w-4.5' + : '[&_svg]:h-4 [&_svg]:w-4'; + + const closeBtnBoxBySize = + size === 'sm' ? 'h-[10px] w-[10px]' : size === 'lg' ? 'h-[15px] w-[15px]' : 'h-[13px] w-[13px]'; + + const closeGlyphSizeBySize = size === 'sm' ? 'text-[16px]' : size === 'lg' ? 'text-[15px]' : 'text-[13px]'; + + const slots = { + base: cn( + baseClasses, + 'min-w-0', + pieceCount > 1 && 'gap-1', + className, + classNames?.base, + interactive ? 'cursor-pointer' : 'cursor-auto', + isSelected && 'ring-2 ring-offset-0 ring-inset ring-accent', + iconBySize + ), + content: cn('truncate', classNames?.content), + dot: cn('inline-block w-2 h-2 rounded-full shrink-0 bg-[var(--chip-dot)]', classNames?.dot), + avatar: cn('shrink-0 ltr:mr-0.3 rtl:ml-0.3', classNames?.avatar), + + closeButton: cn( + 'relative inline-flex items-center justify-center overflow-visible', + 'shrink-0 leading-none select-none pointer-events-auto cursor-pointer', + 'p-0 m-0 ltr:-ml-0.5 rtl:-mr-0.5 rounded', + closeBtnBoxBySize, + closeGlyphSizeBySize, + 'text-white dark:text-white font-bold', + 'hover:bg-transparent hover:ring-0', + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent', + 'focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--surface-bg,white)]', + classNames?.closeButton + ) + }; + + const handleActivate = (e: React.MouseEvent) => { + if (isDisabled) { + return; + } + if (selectable) { + setSelected(!isSelected); + } + onClick?.(e); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (Tag === 'div' && interactive && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault(); + const fake = new MouseEvent('click', { bubbles: true }); + (e.currentTarget as HTMLElement).dispatchEvent(fake); + } + if (closable && (e.key === 'Delete' || e.key === 'Backspace')) { + e.preventDefault(); + onClose?.(); + } + }; + + const handleClose = (e: React.MouseEvent) => { + e.stopPropagation(); + if (isDisabled) { + return; + } + onClose?.(); + }; + + // A11y: dot-only necesita role válido para usar aria-label en un div no interactivo + const isDotOnly = isDot && !hasChildren; + const computedAriaLabel = isDotOnly ? ariaLabel : undefined; + + const computedRole = Tag === 'button' ? undefined : interactive ? 'button' : isDotOnly ? 'img' : undefined; + + const a11yProps = + Tag === 'button' + ? { + type: 'button' as const, + 'aria-disabled': isDisabled || undefined, + disabled: isDisabled || undefined, + 'aria-pressed': selectable ? isSelected : undefined + } + : { + role: computedRole, + tabIndex: interactive ? 0 : undefined, + 'aria-disabled': isDisabled || undefined, + 'aria-pressed': selectable ? isSelected : undefined, + onKeyDown: handleKeyDown + }; + + return { + Tag, + slots, + isDot, + hasChildren, + isSelected, + interactive, + propsBase: { + ...rest, + ...a11yProps, + 'aria-label': computedAriaLabel, + 'data-interactive': interactive ? 'true' : 'false', + onClick: interactive ? handleActivate : onClick + }, + pieces: { avatar, startContent, endContent, children }, + closable, + handleClose + }; +}