Skip to content
Open
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
83 changes: 83 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,89 @@ function App() {
}
```

## Tailwind CSS Prefix

This library uses Tailwind CSS v4 with a `cio:` prefix to avoid conflicts with your application's Tailwind classes. All utility classes and CSS variables are namespaced to ensure styles don't leak or collide.

### Class Categories

The codebase uses two distinct class namespaces. They look similar but serve different purposes:

- **`cio-*` (no colon)** — plain CSS hooks / BEM identifiers attached to component roots and key sub-elements (e.g., `cio-components`, `cio-badge`, `cio-product-card-image`, `cio-filter-visual-swatch`). These are stable selectors used for styling overrides and tests; they are **not** Tailwind utilities and are **never** prefixed with `cio:`.
- **`cio:*` (with colon)** — Tailwind v4 utility classes scoped by the `prefix(cio)` import directive (e.g., `cio:flex`, `cio:hover:bg-primary/90`). The conventions in the rest of this section apply to these.

```tsx
// Both kinds appear together on the same element:
<div className="cio-badge cio:inline-flex cio:items-center cio:gap-1.5">
```

### Class Naming Convention

When adding or customizing Tailwind classes in this library, always use the `cio:` prefix:

```tsx
// Correct
<div className="cio:flex cio:items-center cio:gap-2">

// Incorrect - will not work
<div className="flex items-center gap-2">
```

### Variants and Modifiers

The prefix must come **before** any variants (hover, focus, responsive, etc.):

```tsx
// Correct - prefix first, then variant, then utility
<button className="cio:bg-primary cio:hover:bg-primary/90 cio:disabled:opacity-50">

// Incorrect - variant before prefix
<button className="hover:cio:bg-primary/90 disabled:cio:opacity-50">
```

### Responsive Breakpoints

```tsx
// Correct
<div className="cio:p-2 cio:sm:p-4 cio:lg:p-6">

// Incorrect
<div className="cio:p-2 sm:cio:p-4 lg:cio:p-6">
```

### Arbitrary Values and Selectors

```tsx
// Correct
<div className="cio:w-[200px] cio:[&_svg]:size-4">

// Incorrect
<div className="[&_svg]:cio:size-4">
```

### CSS Variables

CSS variables are prefixed with `--cio-`:

```css
:root {
--cio-primary: oklch(0.1969 0.0101 276.49);
--cio-background: oklch(1 0 0);
--cio-radius: 0.625rem;
}
```

### Customizing Theme

You can override the default theme by setting the CSS variables in your application:

```css
:root {
--cio-primary: #your-brand-color;
--cio-radius: 0.5rem;
}
```

## Local Development

### Development Scripts
Expand Down
50 changes: 50 additions & 0 deletions spec/components/Carousel/Carousel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,33 @@ import { Product } from '@/types/productCardTypes';
import { CarouselRenderProps } from '@/types/carouselTypes';
import { CIO_EVENTS } from '@/utils/events';

// Embla doesn't initialize in jsdom (it relies on real layout), so we stub
// useEmblaCarousel to return a controllable API. Tests that care about the
// canScroll* state mutate `emblaState` before render; otherwise the default
// (both true) keeps existing tests behaving as if scrolling is possible.
const emblaState = vi.hoisted(() => ({
canScrollPrev: true,
canScrollNext: true,
}));

vi.mock('embla-carousel-react', () => {
const useEmblaCarousel = () => {
const ref = (_node: HTMLElement | null) => {};
const api = {
canScrollPrev: () => emblaState.canScrollPrev,
canScrollNext: () => emblaState.canScrollNext,
scrollPrev: vi.fn(),
scrollNext: vi.fn(),
on: vi.fn(),
off: vi.fn(),
rootNode: () => null,
slideNodes: () => [] as HTMLElement[],
};
return [ref, api];
};
return { default: useEmblaCarousel };
});

const mockProducts: Product[] = [
{
id: 'product-1',
Expand Down Expand Up @@ -59,6 +86,9 @@ const mockArticles: Article[] = [

describe('Carousel component', () => {
beforeEach(() => {
emblaState.canScrollPrev = true;
emblaState.canScrollNext = true;

// Mock IntersectionObserver - Required by Embla Carousel for detecting when carousel
// items enter/exit the viewport. Used for lazy loading and visibility tracking.
// Not available in jsdom test environment.
Expand Down Expand Up @@ -580,6 +610,26 @@ describe('Carousel component', () => {
fireEvent.click(prevButton);
expect(mockScrollPrev).toHaveBeenCalled();
});

test('previous nav button gets cio:invisible when canScrollPrev is false', () => {
emblaState.canScrollPrev = false;
emblaState.canScrollNext = true;

const { container } = render(<CioCarousel items={mockProducts} />);

const prevButton = container.querySelector('[data-slot="carousel-previous"]');
expect(prevButton).toHaveClass('cio:invisible');
});

test('previous nav button does not have cio:invisible when canScrollPrev is true', () => {
emblaState.canScrollPrev = true;
emblaState.canScrollNext = true;

const { container } = render(<CioCarousel items={mockProducts} />);

const prevButton = container.querySelector('[data-slot="carousel-previous"]');
expect(prevButton).not.toHaveClass('cio:invisible');
});
});

describe('Empty State', () => {
Expand Down
31 changes: 21 additions & 10 deletions spec/components/Chip/Chip.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react';
import { render, screen, cleanup } from '@testing-library/react';
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
import { describe, test, expect, afterEach } from 'vitest';
import Chip from '@/components/chip';

Expand Down Expand Up @@ -44,49 +44,60 @@ describe('Chip component', () => {
expect(container).toHaveAttribute('data-slot', 'chip');
expect(container.querySelector('img')).toBeInTheDocument();
});

test('adds cio:bg-gray-200 to chip container when image fails to load', () => {
const { container } = render(
<Chip type='image' value='https://example.com/missing.jpg' name='broken' />,
);
const img = screen.getByAltText('broken') as HTMLImageElement;
fireEvent.error(img);
const chipContainer = container.querySelector('[data-slot="chip"]');
expect(chipContainer).toHaveClass('cio:bg-gray-200');
expect(img.style.display).toBe('none');
});
});

describe('empty value fallback', () => {
test('renders white fallback when value is empty string', () => {
render(<Chip type='color' value='' name='Empty' />);
const element = screen.getByRole('img', { name: 'Empty' });
expect(element).toBeInTheDocument();
expect(element.classList.contains('bg-white')).toBeTruthy();
expect(element.classList.contains('cio:bg-white')).toBeTruthy();
});

test('renders white fallback when value is whitespace', () => {
render(<Chip type='color' value=' ' name='Whitespace' />);
const element = screen.getByRole('img', { name: 'Whitespace' });
expect(element.classList.contains('bg-white')).toBeTruthy();
expect(element.classList.contains('cio:bg-white')).toBeTruthy();
});

test('renders white fallback for image type with empty value', () => {
render(<Chip type='image' value='' name='Empty Image' />);
const element = screen.getByRole('img', { name: 'Empty Image' });
expect(element.classList.contains('bg-white')).toBeTruthy();
expect(element.classList.contains('cio:bg-white')).toBeTruthy();
});
});

describe('size variants', () => {
test('applies sm size class', () => {
render(<Chip type='color' value='#000' name='Small' size='sm' />);
const element = screen.getByRole('img', { name: 'Small' });
expect(element.classList.contains('w-4')).toBeTruthy();
expect(element.classList.contains('h-4')).toBeTruthy();
expect(element.classList.contains('cio:w-4')).toBeTruthy();
expect(element.classList.contains('cio:h-4')).toBeTruthy();
});

test('applies md size class (default)', () => {
render(<Chip type='color' value='#000' name='Medium' />);
const element = screen.getByRole('img', { name: 'Medium' });
expect(element.classList.contains('w-6')).toBeTruthy();
expect(element.classList.contains('h-6')).toBeTruthy();
expect(element.classList.contains('cio:w-6')).toBeTruthy();
expect(element.classList.contains('cio:h-6')).toBeTruthy();
});

test('applies lg size class', () => {
render(<Chip type='color' value='#000' name='Large' size='lg' />);
const element = screen.getByRole('img', { name: 'Large' });
expect(element.classList.contains('w-8')).toBeTruthy();
expect(element.classList.contains('h-8')).toBeTruthy();
expect(element.classList.contains('cio:w-8')).toBeTruthy();
expect(element.classList.contains('cio:h-8')).toBeTruthy();
});
});

Expand Down
4 changes: 2 additions & 2 deletions spec/components/FilterOption/FilterOption.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -236,10 +236,10 @@ describe('FilterOption component', () => {
expect(listItem.classList.contains('my-custom-class')).toBeTruthy();
});

test('has text-base class by default', () => {
test('has cio:text-base class by default', () => {
render(<FilterOption id='test-1' optionValue='red' displayValue='Red' onChange={() => {}} />);
const listItem = screen.getByRole('listitem');
expect(listItem.classList.contains('text-base')).toBeTruthy();
expect(listItem.classList.contains('cio:text-base')).toBeTruthy();
});
});

Expand Down
16 changes: 16 additions & 0 deletions spec/components/FilterOptionVisual/FilterOptionVisual.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,22 @@ describe('FilterOptionVisual component', () => {
const swatch = document.querySelector('.cio-filter-visual-swatch');
expect(swatch).toBeInTheDocument();
});

test('swatch has cio:mr-2 and cio:shrink-0 spacing classes', () => {
render(
<FilterOptionVisual
id='test-1'
optionValue='red'
displayValue='Red'
visualType='color'
visualValue='#FF0000'
onChange={() => {}}
/>,
);
const swatch = document.querySelector('.cio-filter-visual-swatch');
expect(swatch).toHaveClass('cio:mr-2');
expect(swatch).toHaveClass('cio:shrink-0');
});
});

describe('data attributes', () => {
Expand Down
2 changes: 1 addition & 1 deletion spec/components/product-card/product-card.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,7 @@ describe('ProductCard component', () => {

expect(salePrice).toBeInTheDocument();
expect(originalPrice).toBeInTheDocument();
expect(originalPrice).toHaveClass('line-through');
expect(originalPrice).toHaveClass('cio:line-through');
});

test('shows only regular price when no sale price', () => {
Expand Down
33 changes: 17 additions & 16 deletions src/components/badge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,32 +5,33 @@ import { ComponentOverrideProps, IncludeComponentOverrides } from '@/types';
import { cva, VariantProps } from 'class-variance-authority';

const badgeVariants = cva(
"cio-components cio-badge inline-flex items-center gap-1.5 whitespace-nowrap font-medium transition-all [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-3 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive overflow-hidden tracking-tighter",
"cio-components cio-badge cio:inline-flex cio:items-center cio:gap-1.5 cio:whitespace-nowrap cio:font-medium cio:transition-all cio:[&_svg]:pointer-events-none cio:[&_svg:not([class*='cio:size-'])]:size-3 cio:shrink-0 cio:[&_svg]:shrink-0 cio:outline-none cio:focus-visible:border-ring cio:focus-visible:ring-ring/50 cio:focus-visible:ring-[3px] cio:aria-invalid:ring-destructive/20 cio:dark:aria-invalid:ring-destructive/40 cio:aria-invalid:border-destructive cio:overflow-hidden cio:tracking-tighter",
Comment thread
constructor-claude-bedrock[bot] marked this conversation as resolved.
Comment thread
constructor-claude-bedrock[bot] marked this conversation as resolved.
Comment thread
constructor-claude-bedrock[bot] marked this conversation as resolved.
Comment thread
esezen marked this conversation as resolved.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important Issue: The arbitrary variant cio:[&_svg:not([class*='cio:size-'])]:size-3 is malformed. The utility at the end (size-3) is not prefixed with cio:, so it will be treated as an unprefixed Tailwind class and will not be scoped. It should be written as cio:[&_svg:not([class*='cio:size-'])]:size-3 where the entire thing is a single Tailwind token — but the correct form when using prefix(cio) is cio:[&_svg:not([class*='cio:size-'])]:cio:size-3, or more practically just cio:[&_svg:not([class*='size-'])]:size-3 (using the old check pattern) since the arbitrary variant modifier and the utility are separate tokens. The same issue applies in button.tsx. Verify the generated CSS contains the expected rule for these SVG size guards.

{
variants: {
variant: {
default:
'border-transparent bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
secondary: 'border-transparent bg-secondary shadow-xs hover:bg-secondary/90',
'cio:border-transparent cio:bg-primary cio:text-primary-foreground cio:shadow-xs cio:hover:bg-primary/90',
secondary:
'cio:border-transparent cio:bg-secondary cio:shadow-xs cio:hover:bg-secondary/90',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
'cio:border cio:bg-background cio:shadow-xs cio:hover:bg-accent cio:hover:text-accent-foreground cio:dark:bg-input/30 cio:dark:border-input cio:dark:hover:bg-input/50',
destructive:
'border-transparent bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
'cio:border-transparent cio:bg-destructive cio:text-white cio:shadow-xs cio:hover:bg-destructive/90 cio:focus-visible:ring-destructive/20 cio:dark:focus-visible:ring-destructive/40 cio:dark:bg-destructive/60',
},
size: {
sm: 'h-4 px-1 text-xs leading-3',
md: 'h-5 py-1 px-2 text-[13px] leading-4',
lg: 'h-6 py-1 px-2 text-sm leading-4',
sm: 'cio:h-4 cio:px-1 cio:text-xs cio:leading-3',
md: 'cio:h-5 cio:py-1 cio:px-2 cio:text-[13px] cio:leading-4',
lg: 'cio:h-6 cio:py-1 cio:px-2 cio:text-sm cio:leading-4',
},
shape: {
beveled: 'rounded-sm',
rounded: 'rounded-full',
text: 'bg-transparent',
sharp: 'rounded-none',
beveled: 'cio:rounded-sm',
rounded: 'cio:rounded-full',
text: 'cio:bg-transparent',
sharp: 'cio:rounded-none',
},
state: {
default: '',
disabled: 'text-[#0A0F2940] bg-secondary pointer-events-none',
disabled: 'cio:text-[#0A0F2940] cio:bg-secondary cio:pointer-events-none',
},
isNumber: {
true: '',
Expand All @@ -41,17 +42,17 @@ const badgeVariants = cva(
{
isNumber: true,
size: 'sm',
class: 'px-0 min-w-4 justify-center',
class: 'cio:px-0 cio:min-w-4 cio:justify-center',
},
{
isNumber: true,
size: 'md',
class: 'p-0.5 min-w-5 justify-center',
class: 'cio:p-0.5 cio:min-w-5 cio:justify-center',
},
{
isNumber: true,
size: 'lg',
class: 'p-0.5 min-w-6 justify-center',
class: 'cio:p-0.5 cio:min-w-6 cio:justify-center',
},
],
defaultVariants: {
Expand Down
31 changes: 16 additions & 15 deletions src/components/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,30 +5,31 @@ import { ComponentOverrideProps, IncludeComponentOverrides } from '@/types';
import { cva, VariantProps } from 'class-variance-authority';

const buttonVariants = cva(
"cio-components cio-button cursor-pointer inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-sm text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive cursor-pointer",
"cio-components cio-button cio:cursor-pointer cio:inline-flex cio:items-center cio:justify-center cio:gap-2 cio:whitespace-nowrap cio:rounded-sm cio:text-sm cio:font-medium cio:transition-all cio:disabled:pointer-events-none cio:disabled:opacity-50 cio:[&_svg]:pointer-events-none cio:[&_svg:not([class*='cio:size-'])]:size-4 cio:shrink-0 cio:[&_svg]:shrink-0 cio:outline-none cio:focus-visible:border-ring cio:focus-visible:ring-ring/50 cio:focus-visible:ring-[3px] cio:aria-invalid:ring-destructive/20 cio:dark:aria-invalid:ring-destructive/40 cio:aria-invalid:border-destructive",
Comment thread
constructor-claude-bedrock[bot] marked this conversation as resolved.
Comment thread
esezen marked this conversation as resolved.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important Issue: Same malformed arbitrary variant as in badge.tsx: cio:[&_svg:not([class*='cio:size-'])]:size-4 — the fallback utility size-4 is not prefixed. Additionally, the original button.tsx had a duplicate cursor-pointer at the end of the base class string (a pre-existing bug). The new version removes one of them, which is correct, but worth confirming that cio:cursor-pointer is still present (it is, at the start of the string).

{
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
default: 'cio:bg-primary cio:text-primary-foreground cio:shadow-xs cio:hover:bg-primary/90',
secondary:
'cio:bg-secondary cio:text-secondary-foreground cio:shadow-xs cio:hover:bg-secondary/80',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
'cio:border cio:bg-background cio:shadow-xs cio:hover:bg-accent cio:hover:text-accent-foreground cio:dark:bg-input/30 cio:dark:border-input cio:dark:hover:bg-input/50',
ghost: 'cio:hover:bg-accent cio:hover:text-accent-foreground cio:dark:hover:bg-accent/50',
link: 'cio:text-primary cio:underline-offset-4 cio:hover:underline',
destructive:
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
'cio:bg-destructive cio:text-white cio:shadow-xs cio:hover:bg-destructive/90 cio:focus-visible:ring-destructive/20 cio:dark:focus-visible:ring-destructive/40 cio:dark:bg-destructive/60',
},
size: {
sm: 'h-6 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
md: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
default: 'h-10 px-4 py-2 has-[>svg]:px-3',
xl: 'h-12 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
sm: 'cio:h-6 cio:rounded-md cio:gap-1.5 cio:px-3 cio:has-[>svg]:px-2.5',
md: 'cio:h-8 cio:rounded-md cio:gap-1.5 cio:px-3 cio:has-[>svg]:px-2.5',
default: 'cio:h-10 cio:px-4 cio:py-2 cio:has-[>svg]:px-3',
xl: 'cio:h-12 cio:rounded-md cio:px-6 cio:has-[>svg]:px-4',
icon: 'cio:size-9',
},
shape: {
beveled: 'rounded-sm',
rounded: 'rounded-full',
sharp: 'rounded-none',
beveled: 'cio:rounded-sm',
rounded: 'cio:rounded-full',
sharp: 'cio:rounded-none',
},
},
defaultVariants: {
Expand Down
Loading
Loading