Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 16 additions & 6 deletions apps/www/src/content/docs/components/chip/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export const playground = {
},
color: {
type: 'select',
options: ['neutral', 'accent'],
options: ['neutral', 'accent', 'danger', 'success', 'warning'],
defaultValue: 'neutral'
},
isDismissible: {
Expand Down Expand Up @@ -62,11 +62,21 @@ export const sizesDemo = {
export const colorDemo = {
type: 'code',
code: `
<Flex gap={9}>
<Chip color="neutral" variant="outline">Outline</Chip>
<Chip color="neutral" variant="filled">Filled</Chip>
<Chip color="accent" variant="outline">Outline</Chip>
<Chip color="accent" variant="filled">Filled</Chip>
<Flex direction="column" gap={5}>
<Flex gap={9}>
<Chip color="neutral" variant="outline">Neutral</Chip>
<Chip color="accent" variant="outline">Accent</Chip>
<Chip color="danger" variant="outline">Danger</Chip>
<Chip color="success" variant="outline">Success</Chip>
<Chip color="warning" variant="outline">Warning</Chip>
</Flex>
<Flex gap={9}>
<Chip color="neutral" variant="filled">Neutral</Chip>
<Chip color="accent" variant="filled">Accent</Chip>
<Chip color="danger" variant="filled">Danger</Chip>
<Chip color="success" variant="filled">Success</Chip>
<Chip color="warning" variant="filled">Warning</Chip>
</Flex>
</Flex>`
};

Expand Down
6 changes: 5 additions & 1 deletion apps/www/src/content/docs/components/chip/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ Two sizes. `large` is the default; `small` fits inside inputs and table cells.

### Color

Choose between neutral and accent styles to control the visual emphasis.
Five colors. `neutral` and `accent` set emphasis. `danger`, `success` and `warning` show status.

<Demo data={colorDemo} />

Expand All @@ -68,6 +68,8 @@ Renders a compact element for tags, labels, or attributes.

<auto-type-table path="./props.ts" name="ChipProps" />

The chip renders a `<button>` when `onClick` is set and it is not dismissible, and a `<span>` otherwise, so `ref` points to whichever element is rendered.

### Slots

Every rendered part carries a stable `data-slot` attribute for [styling and testing](/docs/styling#with-data-slot):
Expand All @@ -89,3 +91,5 @@ The Chip component has some accessibility features:
- Provides keyboard navigation support
- Makes decorative elements hidden from screen readers
- Keeps contrast ratios correct in all variants and states

`children` is optional, so a chip can be icon-only. When you omit `children`, pass an `aria-label`. The chip derives its label from string children, so without one it has no accessible name.
7 changes: 5 additions & 2 deletions apps/www/src/content/docs/components/chip/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ export interface ChipProps {
size?: 'small' | 'large';

/**
* Color style
* Color style. `danger`, `success` and `warning` convey status.
* @defaultValue "neutral"
*/
color?: 'neutral' | 'accent';
color?: 'neutral' | 'accent' | 'danger' | 'success' | 'warning';

/** ReactNode to display as an icon before the label */
leadingIcon?: React.ReactNode;
Expand Down Expand Up @@ -51,4 +51,7 @@ export interface ChipProps {
* @defaultValue The theme's `radius`
*/
radius?: 'none' | 'small' | 'medium' | 'large' | 'full';

/** Ref to the rendered element. */
ref?: React.Ref<HTMLSpanElement>;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
80 changes: 79 additions & 1 deletion packages/raystack/components/chip/__tests__/chip.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { createRef } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { type IconProps, IconProvider } from '~/icons';
import { Chip } from '../chip';
import styles from '../chip.module.css';

Expand Down Expand Up @@ -35,6 +37,19 @@ describe('Chip', () => {
expect(screen.getByText('Chip with trailing icon')).toBeInTheDocument();
});

it('renders an icon-only chip with no children', () => {
render(
<Chip
leadingIcon={<span data-testid='leading-icon'>🏷️</span>}
aria-label='Filter'
/>
);

const chip = screen.getByRole('status');
expect(chip).toHaveAttribute('aria-label', 'Filter');
expect(screen.getByTestId('leading-icon')).toBeInTheDocument();
});

it('applies custom className', () => {
render(<Chip className='custom-chip'>Test Chip</Chip>);

Expand Down Expand Up @@ -81,7 +96,13 @@ describe('Chip', () => {
});

describe('Colors', () => {
const colors = ['neutral', 'accent'] as const;
const colors = [
'neutral',
'accent',
'danger',
'success',
'warning'
] as const;

it.each(colors)('renders %s color correctly', color => {
render(<Chip color={color}>Test Chip</Chip>);
Expand All @@ -98,6 +119,37 @@ describe('Chip', () => {
});
});

describe('Ref', () => {
it('attaches to the span on the default branch', () => {
const ref = createRef<HTMLSpanElement>();
render(<Chip ref={ref}>Test Chip</Chip>);

expect(ref.current?.tagName).toBe('SPAN');
});

it('attaches to the button on the interactive branch', () => {
const ref = createRef<HTMLButtonElement>();
render(
<Chip ref={ref} onClick={vi.fn()}>
Test Chip
</Chip>
);

expect(ref.current?.tagName).toBe('BUTTON');
});

it('stays a span when dismissible, even with onClick', () => {
const ref = createRef<HTMLSpanElement>();
render(
<Chip ref={ref} isDismissible onClick={vi.fn()} onDismiss={vi.fn()}>
Test Chip
</Chip>
);

expect(ref.current?.tagName).toBe('SPAN');
});
});

describe('Dismissible Behavior', () => {
it('renders dismiss button when isDismissible is true', () => {
render(<Chip isDismissible>Dismissible Chip</Chip>);
Expand All @@ -110,6 +162,32 @@ describe('Chip', () => {
expect(dismissButton).toHaveAttribute('type', 'button');
});

it('uses the shared XIcon registry icon for the dismiss button', () => {
const { container } = render(<Chip isDismissible>Dismissible Chip</Chip>);

const icon = container.querySelector('[data-icon="XIcon"]');
expect(icon).toBeInTheDocument();
expect(icon).toHaveAttribute('data-slot', 'chip-dismiss-icon');
expect(icon).toHaveAttribute('width', '12');
expect(icon).toHaveAttribute('height', '12');
});

it('passes the dismiss icon size to an XIcon override', () => {
const CustomX = (props: IconProps) => (
<svg data-testid='custom-x' {...props} />
);
render(
<IconProvider components={{ XIcon: CustomX }}>
<Chip isDismissible>Dismissible Chip</Chip>
</IconProvider>
);

const icon = screen.getByTestId('custom-x');
expect(icon).toHaveAttribute('data-icon', 'XIcon');
expect(icon).toHaveAttribute('width', '12');
expect(icon).toHaveAttribute('height', '12');
});

it('calls onDismiss when dismiss button is clicked', () => {
const onDismiss = vi.fn();
render(
Expand Down
145 changes: 84 additions & 61 deletions packages/raystack/components/chip/chip.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,6 @@
-webkit-user-select: none;
}

/* Variants */
.chip-variant-outline {
background: transparent;
}

.chip-variant-filled {
background: var(--rs-color-background-base-primary);
}

/* Sizes */
.chip-size-large {
padding: var(--rs-space-2) var(--rs-space-3);
Expand All @@ -38,72 +29,104 @@
padding: var(--rs-space-1) var(--rs-space-2);
}

/* colors - Neutral */
.chip-color-neutral.chip-variant-outline {
border: 0.5px solid var(--rs-color-border-base-secondary);
color: var(--rs-color-foreground-base-secondary);
}

.chip-color-neutral.chip-variant-outline:hover {
color: var(--rs-color-foreground-base-primary);
}

.chip-color-neutral.chip-variant-outline:active,
.chip-color-neutral.chip-variant-outline[data-state="active"] {
border: 0.5px solid var(--rs-color-border-base-emphasis);
color: var(--rs-color-foreground-base-primary);
.chip-color-neutral {
--chip-border: var(--rs-color-border-base-secondary);
/* Neutral outline hover changes text only, not the border. */
--chip-border-hover: var(--rs-color-border-base-secondary);
--chip-border-active: var(--rs-color-border-base-emphasis);
--chip-fg: var(--rs-color-foreground-base-secondary);
--chip-fg-hover: var(--rs-color-foreground-base-primary);
--chip-fg-active: var(--rs-color-foreground-base-primary);
/* A foreground token as the border color. */
--chip-filled-border-hover: var(--rs-color-foreground-base-primary);
--chip-filled-fg-hover: var(--rs-color-foreground-base-primary);
--chip-filled-bg-active: var(--rs-color-background-neutral-primary);
}

.chip-color-accent {
--chip-border: var(--rs-color-border-accent-primary);
--chip-border-hover: var(--rs-color-border-accent-emphasis);
--chip-border-active: var(--rs-color-border-accent-emphasis);
--chip-fg: var(--rs-color-foreground-accent-primary);
--chip-fg-hover: var(--rs-color-foreground-accent-primary-hover);
--chip-fg-active: var(--rs-color-foreground-accent-primary);
--chip-filled-border-hover: var(--rs-color-border-accent-emphasis);
/* Accent filled hover changes the border only, not the text. */
--chip-filled-fg-hover: var(--rs-color-foreground-accent-primary);
--chip-filled-bg-active: var(--rs-color-background-accent-primary);
}

.chip-color-danger {
--chip-border: var(--rs-color-border-danger-primary);
--chip-border-hover: var(--rs-color-border-danger-emphasis);
--chip-border-active: var(--rs-color-border-danger-emphasis);
--chip-fg: var(--rs-color-foreground-danger-primary);
--chip-fg-hover: var(--rs-color-foreground-danger-primary-hover);
--chip-fg-active: var(--rs-color-foreground-danger-primary);
--chip-filled-border-hover: var(--rs-color-border-danger-emphasis);
--chip-filled-fg-hover: var(--rs-color-foreground-danger-primary);
--chip-filled-bg-active: var(--rs-color-background-danger-primary);
}

.chip-color-success {
--chip-border: var(--rs-color-border-success-primary);
--chip-border-hover: var(--rs-color-border-success-emphasis);
--chip-border-active: var(--rs-color-border-success-emphasis);
--chip-fg: var(--rs-color-foreground-success-primary);
--chip-fg-hover: var(--rs-color-foreground-success-primary-hover);
--chip-fg-active: var(--rs-color-foreground-success-primary);
--chip-filled-border-hover: var(--rs-color-border-success-emphasis);
--chip-filled-fg-hover: var(--rs-color-foreground-success-primary);
--chip-filled-bg-active: var(--rs-color-background-success-primary);
}

/* The public value is `warning`, backed by the `attention` tokens, as in Badge. */
.chip-color-warning {
--chip-border: var(--rs-color-border-attention-primary);
--chip-border-hover: var(--rs-color-border-attention-emphasis);
--chip-border-active: var(--rs-color-border-attention-emphasis);
--chip-fg: var(--rs-color-foreground-attention-primary);
--chip-fg-hover: var(--rs-color-foreground-attention-primary-hover);
--chip-fg-active: var(--rs-color-foreground-attention-primary);
--chip-filled-border-hover: var(--rs-color-border-attention-emphasis);
--chip-filled-fg-hover: var(--rs-color-foreground-attention-primary);
--chip-filled-bg-active: var(--rs-color-background-attention-primary);
}

.chip-color-neutral.chip-variant-filled {
background: var(--rs-color-background-base-primary);
border: 0.5px solid var(--rs-color-border-base-secondary);
color: var(--rs-color-foreground-base-secondary);
}

.chip-color-neutral.chip-variant-filled:hover {
border-color: var(--rs-color-foreground-base-primary);
color: var(--rs-color-foreground-base-primary);
background: var(--rs-color-background-base-primary-hover);
}

.chip-color-neutral.chip-variant-filled:active,
.chip-color-neutral.chip-variant-filled[data-state="active"] {
background: var(--rs-color-background-neutral-primary);
color: var(--rs-color-foreground-base-primary);
}

/* colors - Accent */
.chip-color-accent.chip-variant-outline {
border: 0.5px solid var(--rs-color-border-accent-primary);
color: var(--rs-color-foreground-accent-primary);
/* Variants */
.chip-variant-outline {
background: transparent;
border: 0.5px solid var(--chip-border);
color: var(--chip-fg);
}

.chip-color-accent.chip-variant-outline:hover {
border-color: var(--rs-color-border-accent-emphasis);
color: var(--rs-color-foreground-accent-primary-hover);
.chip-variant-outline:hover {
border-color: var(--chip-border-hover);
color: var(--chip-fg-hover);
}

.chip-color-accent.chip-variant-outline:active,
.chip-color-accent.chip-variant-outline[data-state="active"] {
border: 0.5px solid var(--rs-color-border-accent-emphasis);
color: var(--rs-color-foreground-accent-primary);
.chip-variant-outline:active,
.chip-variant-outline[data-state="active"] {
border-color: var(--chip-border-active);
color: var(--chip-fg-active);
}

.chip-color-accent.chip-variant-filled {
.chip-variant-filled {
background: var(--rs-color-background-base-primary);
border: 0.5px solid var(--rs-color-border-accent-primary);
color: var(--rs-color-foreground-accent-primary);
border: 0.5px solid var(--chip-border);
color: var(--chip-fg);
}

.chip-color-accent.chip-variant-filled:hover {
border-color: var(--rs-color-border-accent-emphasis);
.chip-variant-filled:hover {
border-color: var(--chip-filled-border-hover);
color: var(--chip-filled-fg-hover);
background: var(--rs-color-background-base-primary-hover);
}

.chip-color-accent.chip-variant-filled:active,
.chip-color-accent.chip-variant-filled[data-state="active"] {
background: var(--rs-color-background-accent-primary);
color: var(--rs-color-foreground-accent-primary);
.chip-variant-filled:active,
.chip-variant-filled[data-state="active"] {
background: var(--chip-filled-bg-active);
color: var(--chip-fg-active);
}

/* Icons */
Expand Down
Loading
Loading