+
@@ -613,14 +622,9 @@ export const HighlightedDates: Story = {
render: () => {
function getContrastText(bgColor: string): string {
if (!bgColor) {
- return '#222';
+ return '#111827';
}
- const hex = bgColor.replace('#', '');
- const r = parseInt(hex.substring(0, 2), 16);
- const g = parseInt(hex.substring(2, 4), 16);
- const b = parseInt(hex.substring(4, 6), 16);
- const yiq = (r * 299 + g * 587 + b * 114) / 1000;
- return yiq >= 128 ? '#222' : '#fff';
+ return '#111827';
}
const today = new Date();
@@ -639,7 +643,12 @@ export const HighlightedDates: Story = {
}
];
- return
;
+ return (
+
+
+
+
+ );
},
parameters: {
docs: {
@@ -654,19 +663,25 @@ export const Sizes: Story = {
render: () => {
const [size, setSize] = useState<'sm' | 'md' | 'lg'>('md');
const [isDark, setIsDark] = useState(() => {
- if (typeof document !== 'undefined') {
- return document.body.classList.contains('dark') || document.body.getAttribute('data-theme') === 'dark';
+ if (typeof document !== 'undefined' && document.documentElement) {
+ return (
+ document.documentElement.classList.contains('dark') ||
+ document.documentElement.getAttribute('data-theme') === 'dark'
+ );
}
return false;
});
useEffect(() => {
const handler = () => {
- setIsDark(document.body.classList.contains('dark') || document.body.getAttribute('data-theme') === 'dark');
+ setIsDark(
+ document.documentElement.classList.contains('dark') ||
+ document.documentElement.getAttribute('data-theme') === 'dark'
+ );
};
window.addEventListener('themechange', handler);
const observer = new MutationObserver(handler);
- observer.observe(document.body, { attributes: true, attributeFilter: ['class', 'data-theme'] });
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'data-theme'] });
return () => {
window.removeEventListener('themechange', handler);
observer.disconnect();
@@ -676,6 +691,7 @@ export const Sizes: Story = {
const textColor = isDark ? '#fff' : '#222';
return (
+
@@ -768,18 +785,24 @@ export const WithRadius: Story = {
render: () => {
const [radius, setRadius] = useState
('md');
const [isDark, setIsDark] = useState(() => {
- if (typeof document !== 'undefined') {
- return document.body.classList.contains('dark') || document.body.getAttribute('data-theme') === 'dark';
+ if (typeof document !== 'undefined' && document.documentElement) {
+ return (
+ document.documentElement.classList.contains('dark') ||
+ document.documentElement.getAttribute('data-theme') === 'dark'
+ );
}
return false;
});
useEffect(() => {
const handler = () => {
- setIsDark(document.body.classList.contains('dark') || document.body.getAttribute('data-theme') === 'dark');
+ setIsDark(
+ document.documentElement.classList.contains('dark') ||
+ document.documentElement.getAttribute('data-theme') === 'dark'
+ );
};
window.addEventListener('themechange', handler);
const observer = new MutationObserver(handler);
- observer.observe(document.body, { attributes: true, attributeFilter: ['class', 'data-theme'] });
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'data-theme'] });
return () => {
window.removeEventListener('themechange', handler);
observer.disconnect();
@@ -789,6 +812,7 @@ export const WithRadius: Story = {
const textColor = isDark ? '#fff' : '#222';
return (
+
diff --git a/src/components/atoms/calendar/Calendar.tsx b/src/components/atoms/calendar/Calendar.tsx
index 59a3c7cd..f71917bf 100644
--- a/src/components/atoms/calendar/Calendar.tsx
+++ b/src/components/atoms/calendar/Calendar.tsx
@@ -293,7 +293,8 @@ export const Calendar: React.FC
= ({
isDisabled: day.isDisabled,
isInRange: day.isInRange || isDragInRange,
isRangeStart: day.isRangeStart || isDragRangeStart,
- isRangeEnd: day.isRangeEnd || isDragRangeEnd
+ isRangeEnd: day.isRangeEnd || isDragRangeEnd,
+ theme
}),
highlightClass,
readOnly ? 'pointer-events-none select-none' : '',
diff --git a/src/components/atoms/chip/Chip.stories.tsx b/src/components/atoms/chip/Chip.stories.tsx
new file mode 100644
index 00000000..34670480
--- /dev/null
+++ b/src/components/atoms/chip/Chip.stories.tsx
@@ -0,0 +1,289 @@
+import { action } from '@storybook/addon-actions';
+import type { Meta, StoryObj } from '@storybook/react';
+import { Check, Trash2 } from 'lucide-react';
+import React from 'react';
+import { Avatar } from '../avatar';
+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 (`onClick`/`selectable`), selectable (controlled or uncontrolled), and closable.
+ * - `as` is a preference, not an absolute guarantee: when `closable` + interactive are combined, the chip uses a split-actions group with sibling buttons.
+ * - Accessible via `ariaLabel` for chips without readable text; close action uses contextual label (`Remove
+ )
+};
+
+/** With text → shows a circular indicator before the label. */
+export const DotWithText: Story = {
+ args: { variant: 'dot', color: 'primary', children: 'Pending' },
+ render: (args) => (
+
+
+
+ )
+};
+
+/** Dot only → provide `ariaLabel` for accessibility. */
+export const DotOnlyAccessible: Story = {
+ args: { variant: 'dot', color: 'primary', ariaLabel: 'Online status' }
+};
+
+/** You can override slot styles with `classNames`. */
+export const WithClassNamesOverrides: Story = {
+ args: {
+ children: 'Custom Slots',
+ classNames: {
+ base: 'bg-primary text-text-dark hover:bg-primary-hover focus-visible:shadow-glow-focus-light dark:focus-visible:shadow-glow-focus-dark',
+ content: 'tracking-wide',
+ closeButton: 'bg-white-tint-high hover:bg-white-tint-strong'
+ },
+ closable: true,
+ animation: 'bounce',
+ as: 'div'
+ }
+};
+
+/** Stress test for long labels */
+export const Stress: Story = {
+ render: () => (
+
+
+
+
+ }
+ endContent={
+
+
+
+ }
+ >
+ Truncated: Very very very long label that should nicely
+
+
+ )
+};
+
+export const ClosableAndClickable: Story = {
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'When `closable` and click behavior are combined, the chip uses a split-actions group (two sibling buttons). Primary action triggers `onClick`; close action triggers `onClose` only.'
+ }
+ }
+ },
+ args: {
+ children: 'Closable + click',
+ closable: true,
+ onClose: action('chip.close'),
+ onClick: action('chip.click')
+ }
+};
+
+export const DisabledAndClosable: Story = {
+ args: {
+ children: 'Disabled + closable',
+ closable: true,
+ disabled: true,
+ onClose: action('chip.close')
+ }
+};
+
+export const ButtonAndClosable: Story = {
+ parameters: {
+ docs: {
+ description: {
+ story:
+ '`as="button"` is treated as a preference. In `closable` + interactive mode the chip renders split-actions markup to avoid nested interactive controls.'
+ }
+ }
+ },
+ args: {
+ children: 'Button + closable',
+ as: 'button',
+ closable: true,
+ onClose: action('chip.close'),
+ onClick: action('chip.click')
+ }
+};
diff --git a/src/components/atoms/chip/Chip.test.tsx b/src/components/atoms/chip/Chip.test.tsx
new file mode 100644
index 00000000..b4e1fddd
--- /dev/null
+++ b/src/components/atoms/chip/Chip.test.tsx
@@ -0,0 +1,319 @@
+/**
+ * Chip.test.tsx — Behavior tests for Stack-and-Flow Design System
+ *
+ * STRATEGY:
+ * - Hook (useChip): tested with renderHook → pure decision logic
+ * - Component (Chip): tested with render + screen + userEvent → observable behavior
+ *
+ * WHAT we test: accessibility, keyboard interaction, controlled/uncontrolled selection,
+ * closable/clickable behavior and disabled guards.
+ * WHAT we do NOT test: internal class strings or implementation details.
+ */
+
+import { fireEvent, render, renderHook, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { describe, expect, it, vi } from 'vitest';
+
+vi.mock('../icon/Icon', () => ({
+ default: () => null
+}));
+
+import { Chip } from './Chip';
+import { useChip } from './useChip';
+
+describe('Chip — interactive and accessibility behavior', () => {
+ it('renders as button when interactive and not closable', () => {
+ const { container } = render(
+ undefined}>
+ Clickable
+
+ );
+
+ expect(container.firstElementChild?.tagName).toBe('BUTTON');
+ });
+
+ it('returns defaults from useChip', () => {
+ const { result } = renderHook(() => useChip({}));
+
+ expect(result.current.componentTag).toBe('div');
+ expect(result.current.isDisabled).toBe(false);
+ expect(result.current.interactive).toBe(false);
+ expect(result.current.propsBase['aria-label']).toBe('Chip');
+ expect(result.current.propsBase['data-interactive']).toBe('false');
+ });
+
+ it('handles disabled and isDisabled alias in useChip', () => {
+ const { result: disabledResult } = renderHook(() => useChip({ disabled: true }));
+ expect(disabledResult.current.isDisabled).toBe(true);
+
+ const { result: aliasResult } = renderHook(() => useChip({ isDisabled: true }));
+ expect(aliasResult.current.isDisabled).toBe(true);
+
+ const { result: precedenceResult } = renderHook(() => useChip({ disabled: false, isDisabled: true }));
+ expect(precedenceResult.current.isDisabled).toBe(false);
+ });
+
+ it('marks root as interactive and calls onClick', async () => {
+ const user = userEvent.setup();
+ const onClick = vi.fn();
+
+ render(Interactive);
+
+ const root = screen.getByRole('button', { name: 'Interactive' });
+
+ expect(root).toHaveAttribute('data-interactive', 'true');
+ await user.click(root);
+ expect(onClick).toHaveBeenCalledTimes(1);
+ });
+
+ it('uses split-actions group when closable + interactive is requested', () => {
+ const { container } = render(
+ undefined}>
+ Closable
+
+ );
+
+ expect(container.firstElementChild?.tagName).toBe('DIV');
+ expect(container.firstElementChild).toHaveAttribute('role', 'group');
+ });
+
+ it('uses contextual close label when chip has readable text', () => {
+ render(React);
+
+ expect(screen.getByRole('button', { name: 'Remove React' })).toBeInTheDocument();
+ });
+
+ it('uses fallback chip labels for non-textual content', () => {
+ render(•} />);
+
+ expect(screen.getByLabelText('Chip item (closable)')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Remove chip' })).toBeInTheDocument();
+ });
+
+ it('calls onClose without triggering onClick when close button is clicked', async () => {
+ const user = userEvent.setup();
+ const onClick = vi.fn();
+ const onClose = vi.fn();
+
+ render(
+
+ React
+
+ );
+
+ await user.click(screen.getByRole('button', { name: 'Remove React' }));
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ expect(onClick).not.toHaveBeenCalled();
+ expect(onClose.mock.calls[0][0]?.type).toBe('click');
+ });
+
+ it('calls both onClick (root) and onClose (close button) in closable + clickable mode', async () => {
+ const user = userEvent.setup();
+ const onClick = vi.fn();
+ const onClose = vi.fn();
+
+ render(
+
+ React
+
+ );
+
+ const primary = screen.getByRole('button', { name: 'React' });
+ await user.click(primary);
+ expect(onClick).toHaveBeenCalledTimes(1);
+
+ await user.click(screen.getByRole('button', { name: 'Remove React' }));
+ expect(onClose).toHaveBeenCalledTimes(1);
+ expect(onClick).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not nest buttons in closable + clickable mode', () => {
+ const { container } = render(
+ undefined} onClose={() => undefined}>
+ React
+
+ );
+
+ const primary = screen.getByRole('button', { name: 'React' });
+
+ expect(primary).toBeInTheDocument();
+ expect(primary.querySelector('button')).toBeNull();
+ expect(container.firstElementChild).toHaveAttribute('role', 'group');
+ });
+
+ it('calls onClose on Delete key when closable and enabled', () => {
+ const onClose = vi.fn();
+ const { container } = render(
+
+ React
+
+ );
+
+ fireEvent.keyDown(container.firstElementChild as HTMLElement, { key: 'Delete' });
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ expect(onClose.mock.calls[0][0]?.type).toBe('keydown');
+ });
+
+ it('calls onClose on Backspace key when closable and enabled', () => {
+ const onClose = vi.fn();
+ const { container } = render(
+
+ React
+
+ );
+
+ fireEvent.keyDown(container.firstElementChild as HTMLElement, { key: 'Backspace' });
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ expect(onClose.mock.calls[0][0]?.type).toBe('keydown');
+ });
+
+ it('calls onClose on Delete key in split-actions mode (closable + interactive)', () => {
+ const onClose = vi.fn();
+
+ render(
+ undefined} onClose={onClose}>
+ React
+
+ );
+
+ const primaryAction = screen.getByRole('button', { name: 'React' });
+ fireEvent.keyDown(primaryAction, { key: 'Delete' });
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ expect(onClose.mock.calls[0][0]?.type).toBe('keydown');
+ });
+
+ it('calls onClose on Backspace key in split-actions mode (closable + interactive)', () => {
+ const onClose = vi.fn();
+
+ render(
+ undefined} onClose={onClose}>
+ React
+
+ );
+
+ const primaryAction = screen.getByRole('button', { name: 'React' });
+ fireEvent.keyDown(primaryAction, { key: 'Backspace' });
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ expect(onClose.mock.calls[0][0]?.type).toBe('keydown');
+ });
+
+ it('does not trigger onClick or onClose when disabled', async () => {
+ const user = userEvent.setup();
+ const onClick = vi.fn();
+ const onClose = vi.fn();
+
+ render(
+
+ React
+
+ );
+
+ const root = screen.getByText('React').closest('div');
+
+ expect(root).toBeInTheDocument();
+ if (!root) {
+ throw new Error('Chip root not found');
+ }
+ expect(root).toHaveAttribute('aria-disabled', 'true');
+ expect(root).toHaveAttribute('data-disabled', 'true');
+ expect(root).toHaveAttribute('data-interactive', 'false');
+
+ const closeButton = screen.getByRole('button', { name: 'Remove React' });
+ expect(closeButton).toBeDisabled();
+
+ await user.click(root);
+ await user.click(closeButton);
+
+ expect(onClick).not.toHaveBeenCalled();
+ expect(onClose).not.toHaveBeenCalled();
+ });
+
+ it('falls back to non-interactive root and disabled close action when disabled + closable + onClick', () => {
+ render(
+ undefined} onClose={() => undefined}>
+ React
+
+ );
+
+ expect(screen.queryByRole('button', { name: 'React' })).not.toBeInTheDocument();
+
+ const root = screen.getByText('React').closest('div');
+ expect(root).toHaveAttribute('aria-disabled', 'true');
+ expect(root).toHaveAttribute('data-interactive', 'false');
+
+ const closeButton = screen.getByRole('button', { name: 'Remove React' });
+
+ expect(closeButton).toBeDisabled();
+ });
+
+ it('supports uncontrolled selectable state', async () => {
+ const user = userEvent.setup();
+
+ render(
+
+ Filter
+
+ );
+
+ const chip = screen.getByRole('button', { name: 'Filter' });
+ expect(chip).toHaveAttribute('aria-pressed', 'false');
+
+ await user.click(chip);
+ expect(chip).toHaveAttribute('aria-pressed', 'true');
+ });
+
+ it('supports controlled selectable mode through onSelectedChange', async () => {
+ const user = userEvent.setup();
+ const onSelectedChange = vi.fn();
+
+ render(
+
+ Controlled
+
+ );
+
+ const chip = screen.getByRole('button', { name: 'Controlled' });
+ expect(chip).toHaveAttribute('aria-pressed', 'false');
+
+ await user.click(chip);
+
+ expect(onSelectedChange).toHaveBeenCalledTimes(1);
+ expect(onSelectedChange).toHaveBeenCalledWith(true);
+ // Controlled mode: visual pressed state only changes if parent updates `selected`.
+ expect(chip).toHaveAttribute('aria-pressed', 'false');
+ });
+
+ it('activates interactive div with Enter and Space keyboard keys', () => {
+ const onClick = vi.fn();
+ render(
+
+ Keyboard
+
+ );
+
+ const root = screen.getByRole('button', { name: 'Keyboard' });
+
+ fireEvent.keyDown(root, { key: 'Enter' });
+ fireEvent.keyDown(root, { key: ' ' });
+
+ expect(onClick).toHaveBeenCalledTimes(2);
+ });
+
+ it('prefers explicit ariaLabel when provided', () => {
+ render();
+
+ expect(screen.getByLabelText('Online status')).toBeInTheDocument();
+ });
+
+ it('uses generic accessibility labels when chip has no readable text', () => {
+ render(•} />);
+
+ expect(screen.getByLabelText('Chip item (closable)')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Remove chip' })).toBeInTheDocument();
+ });
+});
diff --git a/src/components/atoms/chip/Chip.tsx b/src/components/atoms/chip/Chip.tsx
new file mode 100644
index 00000000..59b9b574
--- /dev/null
+++ b/src/components/atoms/chip/Chip.tsx
@@ -0,0 +1,95 @@
+import * as React from 'react';
+import Icon from '../icon/Icon';
+import type { ChipProps } from './types';
+import { useChip } from './useChip';
+
+type ChipElement = HTMLDivElement | HTMLButtonElement;
+
+export const Chip = React.forwardRef((props, ref) => {
+ const {
+ componentTag,
+ slots,
+ splitActions,
+ isDot,
+ hasChildren,
+ propsBase,
+ primaryActionProps,
+ pieces,
+ closable,
+ isDisabled,
+ handleClose,
+ closeIconSize,
+ closeButtonAriaLabel
+ } = useChip(props);
+ const { avatar, startContent, endContent, children } = pieces;
+
+ const content = (
+ <>
+ {avatar && {avatar}}
+
+ {startContent && (
+
+ {startContent}
+
+ )}
+
+ {isDot && }
+
+ {hasChildren && {children}}
+
+ {endContent && (
+
+ {endContent}
+
+ )}
+ >
+ );
+
+ return (
+ <>
+ {componentTag === 'button' ? (
+ // The forwarded ref targets either a div or button depending on hook output.
+ // This narrowing is required because JSX ref props cannot express the runtime branch union directly.
+
+ ) : (
+ // Same rationale as above: the forwarded ref is narrowed to the concrete rendered element for this branch.
+ } className={slots.base} {...propsBase}>
+ {splitActions ? (
+
+ {content}
+
+ ) : (
+ content
+ )}
+
+ {closable && (
+
+
+
+ )}
+
+ )}
+ >
+ );
+});
+
+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..53ed5708
--- /dev/null
+++ b/src/components/atoms/chip/index.ts
@@ -0,0 +1,2 @@
+export { Chip } from './Chip';
+export * from './types';
diff --git a/src/components/atoms/chip/types.ts b/src/components/atoms/chip/types.ts
new file mode 100644
index 00000000..0f3a6444
--- /dev/null
+++ b/src/components/atoms/chip/types.ts
@@ -0,0 +1,360 @@
+import { cva, type VariantProps } from 'class-variance-authority';
+import type * as React from 'react';
+
+export const chipVariants = cva(
+ [
+ 'chip relative max-w-full min-w-0',
+ 'transition-[transform,box-shadow] 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',
+ 'data-[disabled=true]:opacity-60 data-[disabled=true]:cursor-not-allowed',
+ '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)]',
+ 'data-[interactive=true]:active:translate-y-[1px] data-[interactive=true]:active:scale-[0.985]'
+ ],
+ {
+ 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-5 px-1 gap-0.5 fs-small',
+ '[--chip-h:theme(spacing.5)]' // 20px
+ ].join(' '),
+ md: [
+ 'h-6 px-1.5 gap-0.5 fs-base',
+ '[--chip-h:theme(spacing.6)]' // 24px
+ ].join(' '),
+ lg: [
+ 'h-7 px-2 gap-1 fs-h6',
+ '[--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-brand-light-darkest)] data-[interactive=true]:hover:bg-[var(--color-red-100)] dark:text-[var(--color-brand-dark)] dark:data-[interactive=true]:hover:bg-[#1a0008]'
+ },
+ {
+ color: 'primary',
+ variant: 'flat',
+ class:
+ 'bg-[var(--color-red-100)] text-[var(--color-brand-light-darkest)] data-[interactive=true]:hover:bg-[var(--color-red-200)] dark:bg-[#2a000d] dark:text-[var(--color-brand-dark)] dark:data-[interactive=true]:hover:bg-[#330011]'
+ },
+ {
+ color: 'primary',
+ variant: 'faded',
+ class: [
+ 'bg-[var(--color-red-100)] border-[var(--color-red-400)]',
+ 'text-[var(--color-brand-light-darkest)] data-[interactive=true]:hover:bg-[var(--color-red-200)]',
+ 'dark:bg-[#1a0008] dark:border-[var(--color-brand-dark)] dark:text-[var(--color-brand-dark)]',
+ 'dark:data-[interactive=true]:hover:bg-[#24000b]'
+ ].join(' ')
+ },
+ {
+ color: 'primary',
+ variant: 'bordered',
+ class:
+ 'text-[var(--color-brand-light-darkest)] border-[var(--color-brand-light-darkest)] data-[interactive=true]:hover:bg-[var(--color-red-100)] dark:text-[var(--color-brand-dark)] dark:border-[var(--color-brand-dark)] dark:data-[interactive=true]:hover:bg-[#1a0008]'
+ },
+ {
+ color: 'primary',
+ variant: 'shadow',
+ class: [
+ 'bg-[var(--color-primary)] text-[var(--color-text-dark)] border border-transparent [--chip-shadow:var(--color-primary)]',
+ 'shadow-[0_1px_2px_rgba(0,0,0,.12),0_3px_8px_color-mix(in_srgb,var(--chip-shadow)_26%,transparent)]',
+ 'data-[interactive=true]:hover:shadow-[0_2px_4px_rgba(0,0,0,.14),0_5px_12px_color-mix(in_srgb,var(--chip-shadow)_34%,transparent)]',
+ 'dark:shadow-[0_1px_2px_rgba(0,0,0,.28),0_3px_9px_color-mix(in_srgb,var(--chip-shadow)_22%,transparent)]',
+ 'data-[interactive=true]:active:translate-y-[0.5px]'
+ ].join(' ')
+ },
+ {
+ color: 'primary',
+ variant: 'dot',
+ class:
+ 'text-[var(--color-brand-light-darkest)] border-[var(--color-red-400)] [--chip-dot:var(--color-primary)] dark:text-[var(--color-brand-dark)] dark:border-[var(--color-brand-dark)]'
+ },
+
+ /* ----------------- SECONDARY ----------------- */
+ {
+ color: 'secondary',
+ variant: 'solid',
+ class: [
+ 'bg-[var(--color-surface-raised-light)] text-[var(--color-text-light)]',
+ 'data-[interactive=true]:hover:bg-[var(--color-surface-light)]',
+ 'dark:bg-[var(--color-surface-raised-dark)] dark:text-[var(--color-text-dark)] dark:border-[var(--color-border-strong-dark)] dark:data-[interactive=true]:hover:bg-[var(--color-surface-dark)]'
+ ].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-surface-raised-light)] text-[var(--color-text-light)] [--chip-shadow:var(--color-surface-raised-light)]',
+ 'dark:bg-[var(--color-surface-raised-dark)] dark:text-[var(--color-text-dark)] dark:border-[var(--color-border-strong-dark)] dark:[--chip-shadow:var(--color-surface-raised-dark)]',
+ 'shadow-[0_1px_2px_rgba(0,0,0,.12),0_3px_8px_color-mix(in_srgb,var(--chip-shadow)_24%,transparent)]',
+ 'data-[interactive=true]:hover:shadow-[0_2px_4px_rgba(0,0,0,.14),0_5px_12px_color-mix(in_srgb,var(--chip-shadow)_32%,transparent)]'
+ ].join(' ')
+ },
+ {
+ color: 'secondary',
+ variant: 'dot',
+ class:
+ 'text-[var(--color-text-light)] border-[var(--color-gray-light-300)] [--chip-dot:var(--color-text-light)] dark:text-[var(--color-text-dark)] dark:border-[var(--color-border-strong-dark)] dark:[--chip-dot:var(--color-text-dark)]'
+ },
+
+ /* ----------------- SUCCESS ----------------- */
+ {
+ color: 'success',
+ variant: 'solid',
+ class:
+ 'bg-[var(--color-green)] text-[var(--color-text-light)] data-[interactive=true]:hover:bg-[var(--color-green-dark)]'
+ },
+ {
+ color: 'success',
+ variant: 'light',
+ class:
+ 'text-[var(--color-green-dark)] data-[interactive=true]:hover:bg-[color-mix(in_srgb,var(--color-green)_14%,transparent)] dark:text-[var(--color-green-light)] dark:data-[interactive=true]:hover:bg-[color-mix(in_srgb,var(--color-green)_22%,transparent)]'
+ },
+ {
+ color: 'success',
+ variant: 'flat',
+ class:
+ 'bg-[color-mix(in_srgb,var(--color-green)_18%,var(--color-gray-light-100))] text-[var(--color-green-dark)] data-[interactive=true]:hover:bg-[color-mix(in_srgb,var(--color-green)_26%,var(--color-gray-light-100))] dark:bg-[color-mix(in_srgb,var(--color-green)_22%,transparent)] dark:text-[var(--color-green-light)] dark:data-[interactive=true]:hover:bg-[color-mix(in_srgb,var(--color-green)_30%,transparent)]'
+ },
+ {
+ color: 'success',
+ variant: 'faded',
+ class:
+ 'bg-[color-mix(in_srgb,var(--color-green)_10%,var(--color-gray-light-100))] text-[var(--color-green-dark)] border-[color-mix(in_srgb,var(--color-green)_32%,var(--color-gray-light-100))] data-[interactive=true]:hover:bg-[color-mix(in_srgb,var(--color-green)_16%,var(--color-gray-light-100))] dark:bg-[color-mix(in_srgb,var(--color-green)_16%,transparent)] dark:text-[var(--color-green-light)] dark:border-[color-mix(in_srgb,var(--color-green)_38%,transparent)] dark:data-[interactive=true]:hover:bg-[color-mix(in_srgb,var(--color-green)_24%,transparent)]'
+ },
+ {
+ color: 'success',
+ variant: 'bordered',
+ class:
+ 'text-[var(--color-green-dark)] border-[color-mix(in_srgb,var(--color-green)_70%,var(--color-gray-light-100))] data-[interactive=true]:hover:bg-[color-mix(in_srgb,var(--color-green)_12%,transparent)] dark:text-[var(--color-green-light)] dark:border-[color-mix(in_srgb,var(--color-green)_55%,transparent)] dark:data-[interactive=true]:hover:bg-[color-mix(in_srgb,var(--color-green)_20%,transparent)]'
+ },
+ {
+ color: 'success',
+ variant: 'shadow',
+ class: [
+ 'bg-[var(--color-green)] text-[var(--color-text-light)] [--chip-shadow:var(--color-green)]',
+ 'shadow-[0_1px_2px_rgba(0,0,0,.12),0_3px_8px_color-mix(in_srgb,var(--chip-shadow)_24%,transparent)]',
+ 'data-[interactive=true]:hover:shadow-[0_2px_4px_rgba(0,0,0,.14),0_5px_12px_color-mix(in_srgb,var(--chip-shadow)_32%,transparent)]'
+ ].join(' ')
+ },
+ {
+ color: 'success',
+ variant: 'dot',
+ class:
+ 'text-[var(--color-green-dark)] border-[color-mix(in_srgb,var(--color-green)_36%,var(--color-gray-light-100))] [--chip-dot:var(--color-green)] dark:text-[var(--color-green-light)] dark:border-[color-mix(in_srgb,var(--color-green)_44%,transparent)]'
+ },
+
+ /* ----------------- WARNING ----------------- */
+ {
+ color: 'warning',
+ variant: 'solid',
+ class:
+ 'bg-[var(--color-yellow)] text-[var(--color-text-light)] data-[interactive=true]:hover:bg-[var(--color-yellow-dark)] dark:text-[var(--color-text-light)]'
+ },
+ {
+ color: 'warning',
+ variant: 'light',
+ class:
+ 'text-[var(--color-yellow-dark)] data-[interactive=true]:hover:bg-[color-mix(in_srgb,var(--color-yellow)_16%,transparent)] dark:text-[var(--color-yellow-light)] dark:data-[interactive=true]:hover:bg-[color-mix(in_srgb,var(--color-yellow)_22%,transparent)]'
+ },
+ {
+ color: 'warning',
+ variant: 'flat',
+ class:
+ 'bg-[color-mix(in_srgb,var(--color-yellow)_22%,var(--color-gray-light-100))] text-[var(--color-text-light)] data-[interactive=true]:hover:bg-[color-mix(in_srgb,var(--color-yellow)_30%,var(--color-gray-light-100))] dark:bg-[color-mix(in_srgb,var(--color-yellow)_20%,transparent)] dark:text-[var(--color-yellow-light)] dark:data-[interactive=true]:hover:bg-[color-mix(in_srgb,var(--color-yellow)_28%,transparent)]'
+ },
+ {
+ color: 'warning',
+ variant: 'faded',
+ class:
+ 'bg-[color-mix(in_srgb,var(--color-yellow)_12%,var(--color-gray-light-100))] text-[var(--color-text-light)] border-[color-mix(in_srgb,var(--color-yellow)_36%,var(--color-gray-light-100))] data-[interactive=true]:hover:bg-[color-mix(in_srgb,var(--color-yellow)_18%,var(--color-gray-light-100))] dark:bg-[color-mix(in_srgb,var(--color-yellow)_14%,transparent)] dark:text-[var(--color-yellow-light)] dark:border-[color-mix(in_srgb,var(--color-yellow)_40%,transparent)] dark:data-[interactive=true]:hover:bg-[color-mix(in_srgb,var(--color-yellow)_22%,transparent)]'
+ },
+ {
+ color: 'warning',
+ variant: 'bordered',
+ class:
+ 'text-[var(--color-text-light)] border-[color-mix(in_srgb,var(--color-yellow)_78%,var(--color-gray-light-100))] data-[interactive=true]:hover:bg-[color-mix(in_srgb,var(--color-yellow)_14%,transparent)] dark:text-[var(--color-yellow-light)] dark:border-[color-mix(in_srgb,var(--color-yellow)_58%,transparent)] dark:data-[interactive=true]:hover:bg-[color-mix(in_srgb,var(--color-yellow)_20%,transparent)]'
+ },
+ {
+ color: 'warning',
+ variant: 'shadow',
+ class: [
+ 'bg-[var(--color-yellow)] text-[var(--color-text-light)] [--chip-shadow:var(--color-yellow)]',
+ 'shadow-[0_1px_2px_rgba(0,0,0,.12),0_3px_8px_color-mix(in_srgb,var(--chip-shadow)_24%,transparent)]',
+ 'data-[interactive=true]:hover:shadow-[0_2px_4px_rgba(0,0,0,.14),0_5px_12px_color-mix(in_srgb,var(--chip-shadow)_32%,transparent)]'
+ ].join(' ')
+ },
+ {
+ color: 'warning',
+ variant: 'dot',
+ class:
+ 'text-[#1a0a00] border-[color-mix(in_srgb,var(--color-yellow)_52%,var(--color-gray-light-100))] [--chip-dot:var(--color-yellow)] dark:text-[var(--color-yellow-light)] dark:border-[color-mix(in_srgb,var(--color-yellow)_52%,transparent)]'
+ },
+
+ /* ----------------- DANGER ----------------- */
+ {
+ color: 'danger',
+ variant: 'solid',
+ class:
+ 'bg-[var(--color-red-600)] text-[var(--color-text-dark)] data-[interactive=true]:hover:bg-[var(--color-red-700)]'
+ },
+ {
+ color: 'danger',
+ variant: 'light',
+ class:
+ 'text-[var(--color-red-700)] data-[interactive=true]:hover:bg-[color-mix(in_srgb,var(--color-red-600)_14%,transparent)] dark:text-[var(--color-red-300)] dark:data-[interactive=true]:hover:bg-[color-mix(in_srgb,var(--color-red-600)_24%,transparent)]'
+ },
+ {
+ color: 'danger',
+ variant: 'flat',
+ class:
+ 'bg-[var(--color-red-100)] text-[var(--color-red-700)] data-[interactive=true]:hover:bg-[var(--color-red-200)] dark:bg-[color-mix(in_srgb,var(--color-red-700)_34%,transparent)] dark:text-[var(--color-red-200)] dark:data-[interactive=true]:hover:bg-[color-mix(in_srgb,var(--color-red-700)_46%,transparent)]'
+ },
+ {
+ color: 'danger',
+ variant: 'faded',
+ class:
+ 'bg-[var(--color-red-100)] text-[var(--color-text-light)] border-[var(--color-red-400)] data-[interactive=true]:hover:bg-[var(--color-red-200)] dark:bg-[color-mix(in_srgb,var(--color-red-700)_24%,transparent)] dark:text-[var(--color-red-200)] dark:border-[color-mix(in_srgb,var(--color-red-500)_46%,transparent)] dark:data-[interactive=true]:hover:bg-[color-mix(in_srgb,var(--color-red-700)_34%,transparent)]'
+ },
+ {
+ color: 'danger',
+ variant: 'bordered',
+ class:
+ 'text-[var(--color-red-700)] border-[var(--color-red-500)] data-[interactive=true]:hover:bg-[var(--color-red-100)] dark:text-[var(--color-red-300)] dark:border-[var(--color-red-400)] dark:data-[interactive=true]:hover:bg-[color-mix(in_srgb,var(--color-red-700)_28%,transparent)]'
+ },
+ {
+ color: 'danger',
+ variant: 'shadow',
+ class: [
+ 'bg-[var(--color-red-600)] text-[var(--color-text-dark)] [--chip-shadow:var(--color-red-600)]',
+ 'shadow-[0_1px_2px_rgba(0,0,0,.12),0_3px_8px_color-mix(in_srgb,var(--chip-shadow)_28%,transparent)]',
+ 'data-[interactive=true]:hover:shadow-[0_2px_4px_rgba(0,0,0,.14),0_5px_12px_color-mix(in_srgb,var(--chip-shadow)_36%,transparent)]'
+ ].join(' ')
+ },
+ {
+ color: 'danger',
+ variant: 'dot',
+ class:
+ 'text-[var(--color-red-700)] border-[var(--color-red-300)] [--chip-dot:var(--color-red-600)] dark:text-[var(--color-red-300)] dark:border-[var(--color-red-400)]'
+ }
+ ],
+
+ 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'];
+
+type ChipCustomProps = {
+ children?: React.ReactNode;
+ variant?: ChipVariant;
+ color?: ChipColorVariants;
+ size?: ChipSizeVariants;
+ radius?: RadiusSize;
+ animation?: Animation;
+ avatar?: React.ReactNode;
+ startContent?: React.ReactNode;
+ endContent?: React.ReactNode;
+ /**
+ * Preferred root element for the chip.
+ *
+ * Note: when `closable` and interactive are combined, the chip is
+ * rendered as a split-actions group (`div[role="group"]` with
+ * two sibling buttons) to avoid nested interactive controls.
+ */
+ as?: 'div' | 'button';
+ onClick?: React.MouseEventHandler;
+ disabled?: boolean;
+ /** @deprecated Use `disabled` instead. */
+ isDisabled?: boolean;
+ closable?: boolean;
+ onClose?: (event: React.MouseEvent | React.KeyboardEvent) => void;
+ selectable?: boolean;
+ selected?: boolean;
+ defaultSelected?: boolean;
+ onSelectedChange?: (selected: boolean) => void;
+ className?: string;
+ classNames?: Partial>;
+ ariaLabel?: string;
+};
+
+export type ChipProps = ChipCustomProps &
+ Omit, 'children' | 'color' | 'onClick' | 'onClose'> &
+ Omit, 'children' | 'color' | 'onClick' | 'onClose' | 'disabled'>;
diff --git a/src/components/atoms/chip/useChip.ts b/src/components/atoms/chip/useChip.ts
new file mode 100644
index 00000000..725031b0
--- /dev/null
+++ b/src/components/atoms/chip/useChip.ts
@@ -0,0 +1,237 @@
+import * as React from 'react';
+import { cn } from '@/lib/utils';
+import type { ChipProps } from './types';
+import { chipVariants } from './types';
+
+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,
+ disabled,
+ isDisabled,
+ onClick,
+ as,
+ selectable,
+ selected,
+ defaultSelected,
+ onSelectedChange,
+ closable,
+ onClose,
+ ariaLabel,
+ ...rest
+ } = props;
+
+ const isDisabledComputed = disabled ?? isDisabled ?? false;
+
+ 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 = (as === 'button' || !!onClick || !!selectable) && !isDisabledComputed;
+ const splitActions = !!closable && interactive;
+ const wantsButtonTag = as === 'button' || (as == null && interactive);
+ const componentTag: 'div' | 'button' = wantsButtonTag && !closable ? '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 iconBySize =
+ size === 'sm'
+ ? '[&_svg]:h-3.5 [&_svg]:w-3.5'
+ : size === 'lg'
+ ? '[&_svg]:h-5 [&_svg]:w-5'
+ : '[&_svg]:h-4 [&_svg]:w-4';
+
+ const avatarSizeClass = size === 'sm' ? 'chip-avatar-sm' : size === 'lg' ? 'chip-avatar-lg' : 'chip-avatar-md';
+
+ const dotColorClass =
+ color === 'secondary'
+ ? 'chip-dot-secondary'
+ : color === 'success'
+ ? 'chip-dot-success'
+ : color === 'warning'
+ ? 'chip-dot-warning'
+ : color === 'danger'
+ ? 'chip-dot-danger'
+ : 'chip-dot-primary';
+
+ const closeBtnBoxBySize = size === 'sm' ? 'h-3 w-3' : size === 'lg' ? 'h-5 w-5' : 'h-4 w-4';
+
+ const closeIconSizeBySize: 10 | 12 | 14 = size === 'sm' ? 10 : size === 'lg' ? 14 : 12;
+
+ const slots = {
+ base: cn(
+ baseClasses,
+ 'min-w-0',
+ className,
+ classNames?.base,
+ interactive ? 'cursor-pointer min-h-11 min-w-11' : 'cursor-auto',
+ splitActions && 'focus-within:shadow-glow-focus-light dark:focus-within:shadow-glow-focus-dark',
+ isSelected && 'ring-2 ring-offset-0 ring-inset ring-primary dark:ring-brand-dark',
+ iconBySize
+ ),
+ content: cn('truncate', classNames?.content),
+ dot: cn('inline-block w-2 h-2 rounded-full shrink-0', dotColorClass, classNames?.dot),
+ avatar: cn('chip-avatar-media shrink-0 ltr:mr-px rtl:ml-px', avatarSizeClass, classNames?.avatar),
+
+ actionButton: cn(
+ 'inline-flex min-w-0 min-h-11 min-w-11 flex-1 items-center justify-center gap-0.5 rounded-inherit bg-transparent p-0 m-0 border-0 text-inherit',
+ 'focus-visible:outline-none transition-transform duration-200 ease-in-out active:translate-y-px active:scale-95',
+ 'disabled:cursor-not-allowed disabled:opacity-40',
+ classNames?.actionButton
+ ),
+
+ closeButton: cn(
+ 'inline-flex min-h-11 min-w-11 items-center justify-center overflow-visible rounded-full',
+ 'shrink-0 leading-none select-none pointer-events-auto cursor-pointer',
+ 'p-0 m-0 ltr:-ml-0.5 rtl:-mr-0.5',
+ closeBtnBoxBySize,
+ 'text-current/80 hover:text-current',
+ 'hover:bg-transparent hover:ring-0',
+ 'focus-visible:outline-none focus-visible:shadow-glow-focus-light dark:focus-visible:shadow-glow-focus-dark',
+ 'disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-40',
+ classNames?.closeButton
+ )
+ };
+
+ const handleActivate = (e: React.MouseEvent) => {
+ if (isDisabledComputed) {
+ return;
+ }
+ if (selectable) {
+ setSelected(!isSelected);
+ }
+ onClick?.(e);
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (componentTag === 'div' && interactive && (e.key === 'Enter' || e.key === ' ')) {
+ e.preventDefault();
+ const fake = new MouseEvent('click', { bubbles: true });
+ (e.currentTarget as HTMLElement).dispatchEvent(fake);
+ }
+ if (!isDisabledComputed && closable && (e.key === 'Delete' || e.key === 'Backspace')) {
+ e.preventDefault();
+ onClose?.(e);
+ }
+ };
+
+ const handleClose = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ if (isDisabledComputed) {
+ return;
+ }
+ onClose?.(e);
+ };
+
+ const childrenAsText =
+ typeof children === 'string' || typeof children === 'number'
+ ? String(children).trim()
+ : Array.isArray(children)
+ ? children
+ .map((child) => (typeof child === 'string' || typeof child === 'number' ? String(child).trim() : ''))
+ .filter(Boolean)
+ .join(' ')
+ : '';
+
+ const hasReadableText = childrenAsText.length > 0;
+
+ const computedAriaLabel =
+ ariaLabel ?? (hasReadableText ? undefined : closable ? 'Chip item (closable)' : interactive ? 'Chip item' : 'Chip');
+
+ const closeButtonAriaLabel = hasReadableText ? `Remove ${childrenAsText}` : 'Remove chip';
+
+ const isDotOnly = isDot && !hasChildren;
+
+ const computedRole = componentTag === 'button' ? undefined : interactive ? 'button' : isDotOnly ? 'img' : undefined;
+
+ const a11yProps = splitActions
+ ? {
+ role: 'group' as const,
+ 'aria-disabled': isDisabledComputed || undefined
+ }
+ : componentTag === 'button'
+ ? {
+ type: 'button' as const,
+ 'aria-disabled': isDisabledComputed || undefined,
+ disabled: isDisabledComputed || undefined,
+ 'aria-pressed': selectable ? isSelected : undefined
+ }
+ : {
+ role: computedRole,
+ tabIndex: interactive ? 0 : undefined,
+ 'aria-disabled': isDisabledComputed || undefined,
+ 'aria-pressed': selectable ? isSelected : undefined,
+ onKeyDown: handleKeyDown
+ };
+
+ const primaryActionProps = splitActions
+ ? {
+ type: 'button' as const,
+ onClick: handleActivate as React.MouseEventHandler,
+ onKeyDown: handleKeyDown as React.KeyboardEventHandler,
+ disabled: isDisabledComputed || undefined,
+ 'aria-disabled': isDisabledComputed || undefined,
+ 'aria-pressed': selectable ? isSelected : undefined,
+ 'aria-label': computedAriaLabel
+ }
+ : undefined;
+
+ return {
+ componentTag,
+ slots,
+ isDot,
+ hasChildren,
+ isSelected,
+ interactive,
+ splitActions,
+ propsBase: {
+ ...rest,
+ ...a11yProps,
+ 'aria-label': splitActions ? undefined : computedAriaLabel,
+ 'data-interactive': interactive ? 'true' : 'false',
+ 'data-disabled': isDisabledComputed ? 'true' : 'false',
+ onClick: splitActions ? undefined : interactive ? handleActivate : isDisabledComputed ? undefined : onClick
+ },
+ primaryActionProps,
+ pieces: { avatar, startContent, endContent, children },
+ closable,
+ isDisabled: isDisabledComputed,
+ handleClose,
+ closeIconSize: closeIconSizeBySize,
+ closeButtonAriaLabel
+ };
+}
diff --git a/src/components/atoms/dropdown/Dropdown.stories.tsx b/src/components/atoms/dropdown/Dropdown.stories.tsx
index 4d3360c1..554c66b8 100644
--- a/src/components/atoms/dropdown/Dropdown.stories.tsx
+++ b/src/components/atoms/dropdown/Dropdown.stories.tsx
@@ -1,7 +1,5 @@
import { Icon } from '@atoms/icon';
import type { Meta, StoryObj } from '@storybook/react';
-import { Button } from '../button';
-import { Link } from '../link';
import { Dropdown } from './index';
import type { DropdownSchema } from './types';
@@ -131,6 +129,12 @@ export default meta;
type Story = StoryObj;
+const Trigger = ({ label }: { label: string }) => (
+
+ {label}
+
+);
+
const schema: DropdownSchema = [
{ type: 'label', label: 'My Account' },
{ type: 'separator' },
@@ -168,7 +172,7 @@ export const Default: Story = {
loading: false,
className: '',
items: schema,
- children:
+ children:
}
};
/**
@@ -180,7 +184,7 @@ export const Loading: Story = {
render: () => (
-
+
)
@@ -193,12 +197,13 @@ export const CustomTrigger: Story = {
render: () => (
-
- Lorem ipsum
-
+
-
+
+
+ Open image menu
+
)
@@ -211,7 +216,7 @@ export const CustomWidth: Story = {
render: () => (
-
+
)
@@ -224,7 +229,7 @@ export const CustomOffset: Story = {
render: () => (
-
+
)
@@ -237,16 +242,16 @@ export const Position: Story = {
render: () => (
-
+
-
+
-
+
-
+
)
@@ -259,13 +264,13 @@ export const Alignment: Story = {
render: () => (
-
+
-
+
-
+
)
diff --git a/src/components/atoms/dropdown/Dropdown.tsx b/src/components/atoms/dropdown/Dropdown.tsx
index e9f295e9..95801652 100644
--- a/src/components/atoms/dropdown/Dropdown.tsx
+++ b/src/components/atoms/dropdown/Dropdown.tsx
@@ -112,7 +112,7 @@ const Dropdown: FC = ({ ...props }) => {
return (
- {props.children}
+ {props.children}
&
{...props}
ref={iconButtonRef}
type='button'
- role={ariaPressed !== undefined ? 'switch' : 'button'}
className={cn('w-auto', iconButtonVariants({ variant, rounded, shadow }), className)}
disabled={disabled}
aria-disabled={disabled || undefined}
diff --git a/src/components/atoms/link/Link.stories.tsx b/src/components/atoms/link/Link.stories.tsx
index 64514716..a28affef 100644
--- a/src/components/atoms/link/Link.stories.tsx
+++ b/src/components/atoms/link/Link.stories.tsx
@@ -1,5 +1,5 @@
-import { fn } from '@storybook/test';
import type { Meta, StoryObj } from '@storybook/react';
+import { fn } from '@storybook/test';
import Link from './Link';
/**
@@ -172,7 +172,7 @@ export const CustomClass: Story = {
size='md'
variant='outlined'
href='https://github.com/egdev6'
- className='!border-blue !bg-blue hover:!bg-blue-dark hover:!border-blue-dark hover:!shadow-blue-dark'
+ className='!border-blue-dark !bg-blue-dark !text-white hover:!bg-blue hover:!border-blue hover:!shadow-blue-dark'
>
Custom Class Link
diff --git a/src/components/molecules/snippet/Snippet.stories.tsx b/src/components/molecules/snippet/Snippet.stories.tsx
index d926282c..fa008666 100644
--- a/src/components/molecules/snippet/Snippet.stories.tsx
+++ b/src/components/molecules/snippet/Snippet.stories.tsx
@@ -257,6 +257,7 @@ export const CustomClassName: Story = {
*/
export const WithAriaControls: Story = {
args: {
+ id: 'custom-snippet',
children: 'npm install @your/package',
'aria-label': 'Copy install command',
'aria-controls': 'custom-snippet'
diff --git a/src/components/molecules/snippet/types.ts b/src/components/molecules/snippet/types.ts
index 05014678..5ee41c97 100644
--- a/src/components/molecules/snippet/types.ts
+++ b/src/components/molecules/snippet/types.ts
@@ -157,6 +157,7 @@ export type SizeButton = Record;
*/
export type SnippetProps = SnippetVariants & {
children: ReactNode;
+ id?: string;
className?: string;
disableCopy?: boolean;
onCopy?: () => void;
diff --git a/src/index.ts b/src/index.ts
index 5e870741..249a64cd 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -3,6 +3,7 @@ export { Avatar } from './components/atoms/avatar';
export { Badge } from './components/atoms/badge';
export { Button } from './components/atoms/button';
export { Calendar } from './components/atoms/calendar';
+export { Chip } from './components/atoms/chip';
export { Divider } from './components/atoms/divider';
export { Dropdown } from './components/atoms/dropdown';
export { Header } from './components/atoms/header';
diff --git a/src/styles/base.css b/src/styles/base.css
index b8bdff7d..17dc08ba 100644
--- a/src/styles/base.css
+++ b/src/styles/base.css
@@ -24,41 +24,41 @@
/* ── Typography scale ──────────────────────────────────────────── */
.fs-h1 {
- font-size: var(--text-h1);
+ font-size: var(--text-h1-compact);
line-height: 1.2;
font-weight: 800;
}
.fs-h2 {
- font-size: var(--text-h2);
+ font-size: var(--text-h2-compact);
line-height: 1.2;
font-weight: 700;
}
.fs-h3 {
- font-size: var(--text-h3);
+ font-size: var(--text-h3-compact);
line-height: 1.2;
font-weight: 700;
}
.fs-h4 {
- font-size: var(--text-h4);
+ font-size: var(--text-h4-compact);
line-height: 1.2;
font-weight: 600;
}
.fs-h5 {
- font-size: var(--text-h5);
+ font-size: var(--text-h5-compact);
line-height: 1.2;
font-weight: 600;
}
.fs-h6 {
- font-size: var(--text-h6);
+ font-size: var(--text-h6-compact);
line-height: 1.2;
font-weight: 600;
}
.fs-base {
- font-size: var(--text-body);
+ font-size: var(--text-body-compact);
line-height: 1.5;
}
.fs-small {
- font-size: var(--text-small);
+ font-size: var(--text-small-compact);
line-height: 1.4;
}
@@ -67,30 +67,30 @@
font-family: var(--font-primary);
}
- @media (max-width: theme("screens.md")) {
+ @media (min-width: 48rem) {
.fs-h1 {
- font-size: var(--text-h1-tablet);
+ font-size: var(--text-h1);
}
.fs-h2 {
- font-size: var(--text-h2-tablet);
+ font-size: var(--text-h2);
}
.fs-h3 {
- font-size: var(--text-h3-tablet);
+ font-size: var(--text-h3);
}
.fs-h4 {
- font-size: var(--text-h4-tablet);
+ font-size: var(--text-h4);
}
.fs-h5 {
- font-size: var(--text-h5-tablet);
+ font-size: var(--text-h5);
}
.fs-h6 {
- font-size: var(--text-h6-tablet);
+ font-size: var(--text-h6);
}
.fs-base {
- font-size: var(--text-body-tablet);
+ font-size: var(--text-body);
}
.fs-small {
- font-size: var(--text-small-tablet);
+ font-size: var(--text-small);
}
}
diff --git a/src/styles/theme.css b/src/styles/theme.css
index 34af3f86..3d5bc5aa 100644
--- a/src/styles/theme.css
+++ b/src/styles/theme.css
@@ -153,6 +153,11 @@
--color-amber: #fbbf24;
--color-amber-hover: #ffd700;
+ /* ── Chip sizing tokens ───────────────────────────────────────── */
+ --size-chip-avatar-sm: 18px;
+ --size-chip-avatar-md: 22px;
+ --size-chip-avatar-lg: 26px;
+
/* ── Glow / Shadow ───────────────────────────────────────────── */
--glow-base: 0 4px 20px rgba(219, 20, 60, 0.15); /* Light */
--glow-active: 0 8px 30px rgba(219, 20, 60, 0.3); /* Light */
@@ -185,6 +190,39 @@
--glow-btn-secondary-light: 0 0 0 1px rgba(219, 20, 60, 0.35), 0 2px 8px rgba(219, 20, 60, 0.12);
--glow-btn-secondary-hover-light: 0 0 0 1px rgba(219, 20, 60, 0.55), 0 4px 12px rgba(219, 20, 60, 0.18);
+ /* Glow de chip shadow variants */
+ --glow-chip-primary-light:
+ 0 1px 2px rgba(0, 0, 0, 0.12), 0 3px 8px color-mix(in srgb, var(--color-primary) 26%, transparent);
+ --glow-chip-primary-hover-light:
+ 0 2px 4px rgba(0, 0, 0, 0.14), 0 5px 12px color-mix(in srgb, var(--color-primary) 34%, transparent);
+ --glow-chip-primary:
+ 0 1px 2px rgba(0, 0, 0, 0.28), 0 3px 9px color-mix(in srgb, var(--color-primary) 22%, transparent);
+ --glow-chip-primary-hover:
+ 0 2px 4px rgba(0, 0, 0, 0.32), 0 5px 12px color-mix(in srgb, var(--color-primary) 30%, transparent);
+
+ --glow-chip-secondary-light:
+ 0 1px 2px rgba(0, 0, 0, 0.12), 0 3px 8px color-mix(in srgb, var(--color-surface-raised-light) 24%, transparent);
+ --glow-chip-secondary-hover-light:
+ 0 2px 4px rgba(0, 0, 0, 0.14), 0 5px 12px color-mix(in srgb, var(--color-surface-raised-light) 32%, transparent);
+ --glow-chip-secondary:
+ 0 1px 2px rgba(0, 0, 0, 0.28), 0 3px 9px color-mix(in srgb, var(--color-surface-raised-dark) 28%, transparent);
+ --glow-chip-secondary-hover:
+ 0 2px 4px rgba(0, 0, 0, 0.32), 0 5px 12px color-mix(in srgb, var(--color-surface-raised-dark) 36%, transparent);
+
+ --glow-chip-success: 0 1px 2px rgba(0, 0, 0, 0.12), 0 3px 8px color-mix(in srgb, var(--color-green) 24%, transparent);
+ --glow-chip-success-hover:
+ 0 2px 4px rgba(0, 0, 0, 0.14), 0 5px 12px color-mix(in srgb, var(--color-green) 32%, transparent);
+
+ --glow-chip-warning:
+ 0 1px 2px rgba(0, 0, 0, 0.12), 0 3px 8px color-mix(in srgb, var(--color-yellow) 24%, transparent);
+ --glow-chip-warning-hover:
+ 0 2px 4px rgba(0, 0, 0, 0.14), 0 5px 12px color-mix(in srgb, var(--color-yellow) 32%, transparent);
+
+ --glow-chip-danger:
+ 0 1px 2px rgba(0, 0, 0, 0.12), 0 3px 8px color-mix(in srgb, var(--color-red-600) 28%, transparent);
+ --glow-chip-danger-hover:
+ 0 2px 4px rgba(0, 0, 0, 0.14), 0 5px 12px color-mix(in srgb, var(--color-red-600) 36%, transparent);
+
/* Glow de focus ring */
--glow-focus-dark: 0 0 0 3px rgba(255, 0, 54, 0.4);
--glow-focus-light: 0 0 0 3px rgba(219, 20, 60, 0.35);
@@ -250,16 +288,16 @@
--text-xs: 0.75rem; /* 12px — badges, tags micro */
--text-code: 93%; /* relativo — code blocks */
- /* Tamaños tablet */
- --text-display-tablet: 2.75rem;
- --text-h1-tablet: 2.4rem;
- --text-h2-tablet: 2rem;
- --text-h3-tablet: 1.75rem;
- --text-h4-tablet: 1.375rem;
- --text-h5-tablet: 1.125rem;
- --text-h6-tablet: 1rem;
- --text-body-tablet: 0.9375rem;
- --text-small-tablet: 0.8125rem;
+ /* Tamaños compactos (base < md) */
+ --text-display-compact: 2.75rem;
+ --text-h1-compact: 2.4rem;
+ --text-h2-compact: 2rem;
+ --text-h3-compact: 1.75rem;
+ --text-h4-compact: 1.375rem;
+ --text-h5-compact: 1.125rem;
+ --text-h6-compact: 1rem;
+ --text-body-compact: 0.9375rem;
+ --text-small-compact: 0.8125rem;
/* Line heights */
--leading-tight: 1.1;