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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ mode details, scoped theming examples, and per-instance overrides.
- **AutocompleteInput** — searchable autocomplete input (combobox)
- **CheckboxGroup** — controlled checkbox group for multi-value selection
- **CheckboxInput** — checkbox with label and description
- **ColorSwatchPicker** — swatch picker for the named theme palette
- **Field** — form field wrapper with label, description, and validation
- **FileInput** — file upload with drag-and-drop support
- **InputGroup** — groups related form inputs with shared label
Expand Down
1 change: 1 addition & 0 deletions site/src/component-categories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export const componentCategories: Record<string, readonly string[]> = {
'AutocompleteInput',
'CheckboxGroup',
'CheckboxInput',
'ColorSwatchPicker',
'Field',
'FileInput',
'InputGroup',
Expand Down
1 change: 1 addition & 0 deletions src/components/Badge/Badge.recipe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export const badgeRecipe = sva({
error: {root: {bg: 'status.error.solid', color: 'status.error.solidFg'}},
blue: {root: {bg: 'surface.blue', color: 'surface.blue.fg'}},
cyan: {root: {bg: 'surface.cyan', color: 'surface.cyan.fg'}},
gray: {root: {bg: 'surface.gray', color: 'surface.gray.fg'}},
green: {root: {bg: 'surface.green', color: 'surface.green.fg'}},
orange: {root: {bg: 'surface.orange', color: 'surface.orange.fg'}},
pink: {root: {bg: 'surface.pink', color: 'surface.pink.fg'}},
Expand Down
8 changes: 8 additions & 0 deletions src/components/Badge/Badge.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@ describe('Badge', () => {
);
});

it('applies gray as a named palette color', () => {
render(<Badge color="gray" data-testid="badge" label="Gray" />);

expect(screen.getByTestId('badge')).toHaveClass(
badgeRecipe({color: 'gray'}).root!,
);
});

it('forwards ref to the root span', () => {
const ref = vi.fn<(element: HTMLSpanElement | null) => void>();

Expand Down
20 changes: 5 additions & 15 deletions src/components/Badge/Badge.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,15 @@
import type {CSSProperties, Ref} from 'react';
import {badgeRecipe} from 'components/Badge/Badge.recipe';
import {Icon, type IconComponent} from 'components/Icon';
import type {ColorName} from 'internal/colorNames';
import {cx} from 'utils/cx';

export type BadgeSize = 'sm' | 'md' | 'lg';

export type BadgeColor =
| 'neutral'
| 'info'
| 'success'
| 'warning'
| 'error'
| 'blue'
| 'cyan'
| 'green'
| 'orange'
| 'pink'
| 'purple'
| 'red'
| 'teal'
| 'yellow';
export type BadgeStatusColor =
'neutral' | 'info' | 'success' | 'warning' | 'error';

export type BadgeColor = ColorName | BadgeStatusColor;

/**
* A compact status label, category marker, or count.
Expand Down
1 change: 1 addition & 0 deletions src/components/Badge/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ export {
type BadgeColor,
type BadgeProps,
type BadgeSize,
type BadgeStatusColor,
} from 'components/Badge/Badge';
181 changes: 181 additions & 0 deletions src/components/ColorSwatchPicker/ColorSwatchPicker.recipe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import {cva, sva, type RecipeVariantProps} from 'styled-system/css';

export const colorSwatchPickerRecipe = cva({
base: {
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
gap: '1',
},
});

/**
* The button reserves `spacing.1` of padding around the circle so the selected
* ring and the focus ring both draw inside the button's own box, never over a
* neighboring swatch. Both rings occupy the same 2px–4px band outside the
* circle, so a focused swatch shows the primary outline in place of its
* selected ring; the check icon still marks which swatch is selected.
*/
export const colorSwatchRecipe = sva({
slots: ['button', 'fill'],
base: {
button: {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
p: '1',
borderWidth: 0,
borderStyle: 'none',
borderRadius: 'full',
bg: 'transparent',
color: 'inherit',
cursor: 'pointer',
_focusVisible: {
outline: 'none',
},
},
fill: {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
borderWidth: 'thin',
borderStyle: 'solid',
borderRadius: 'full',
transitionProperty: 'box-shadow, transform',
transitionDuration: 'fast',
transitionTimingFunction: 'default',
'@media (prefers-reduced-motion: reduce)': {
transitionDuration: '0s',
},
'[role="radio"]:hover &': {
transform: 'scale(1.12)',
},
'[role="radio"]:focus-visible &': {
outlineWidth: 'focus',
outlineStyle: 'solid',
outlineColor: 'primary',
outlineOffset: 'focusOffset',
},
},
},
variants: {
color: {
red: {
fill: {
'--swatch-ring': 'token(colors.surface.red.accent)',
bg: 'surface.red',
borderColor: 'surface.red.accent',
color: 'surface.red.fg',
},
},
orange: {
fill: {
'--swatch-ring': 'token(colors.surface.orange.accent)',
bg: 'surface.orange',
borderColor: 'surface.orange.accent',
color: 'surface.orange.fg',
},
},
yellow: {
fill: {
'--swatch-ring': 'token(colors.surface.yellow.accent)',
bg: 'surface.yellow',
borderColor: 'surface.yellow.accent',
color: 'surface.yellow.fg',
},
},
green: {
fill: {
'--swatch-ring': 'token(colors.surface.green.accent)',
bg: 'surface.green',
borderColor: 'surface.green.accent',
color: 'surface.green.fg',
},
},
teal: {
fill: {
'--swatch-ring': 'token(colors.surface.teal.accent)',
bg: 'surface.teal',
borderColor: 'surface.teal.accent',
color: 'surface.teal.fg',
},
},
cyan: {
fill: {
'--swatch-ring': 'token(colors.surface.cyan.accent)',
bg: 'surface.cyan',
borderColor: 'surface.cyan.accent',
color: 'surface.cyan.fg',
},
},
blue: {
fill: {
'--swatch-ring': 'token(colors.surface.blue.accent)',
bg: 'surface.blue',
borderColor: 'surface.blue.accent',
color: 'surface.blue.fg',
},
},
purple: {
fill: {
'--swatch-ring': 'token(colors.surface.purple.accent)',
bg: 'surface.purple',
borderColor: 'surface.purple.accent',
color: 'surface.purple.fg',
},
},
pink: {
fill: {
'--swatch-ring': 'token(colors.surface.pink.accent)',
bg: 'surface.pink',
borderColor: 'surface.pink.accent',
color: 'surface.pink.fg',
},
},
gray: {
fill: {
'--swatch-ring': 'token(colors.surface.gray.accent)',
bg: 'surface.gray',
borderColor: 'surface.gray.accent',
color: 'surface.gray.fg',
},
},
},
size: {
sm: {fill: {h: '6', w: '6'}},
md: {fill: {h: '8', w: '8'}},
lg: {fill: {h: '10', w: '10'}},
},
isSelected: {
true: {
fill: {
boxShadow:
'0 0 0 2px token(colors.bg), 0 0 0 4px var(--swatch-ring, token(colors.border.emphasized))',
},
},
false: {},
},
isDisabled: {
true: {
button: {
cursor: 'not-allowed',
opacity: 0.55,
},
fill: {
'[role="radio"]:hover &': {
transform: 'none',
},
},
},
false: {},
},
},
defaultVariants: {
size: 'md',
isSelected: false,
isDisabled: false,
},
});

export type ColorSwatchVariants = RecipeVariantProps<typeof colorSwatchRecipe>;
134 changes: 134 additions & 0 deletions src/components/ColorSwatchPicker/ColorSwatchPicker.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import type {Meta, StoryObj} from '@storybook/react-vite';
import {useState} from 'react';
import {Badge} from 'components/Badge';
import {
ColorSwatchPicker,
type ColorName,
type ColorSwatchPickerProps,
} from 'components/ColorSwatchPicker';
import {Tag} from 'components/Tag';

function ColorSwatchPickerStory(
args: ColorSwatchPickerProps,
): React.JSX.Element {
const [value, setValue] = useState(args.value);

return <ColorSwatchPicker {...args} onChange={setValue} value={value} />;
}

function SizesStory(args: ColorSwatchPickerProps): React.JSX.Element {
const [value, setValue] = useState(args.value);

return (
<div style={{display: 'grid', gap: 16, justifyItems: 'start'}}>
<ColorSwatchPicker
{...args}
onChange={setValue}
size="sm"
value={value}
/>
<ColorSwatchPicker
{...args}
onChange={setValue}
size="md"
value={value}
/>
<ColorSwatchPicker
{...args}
onChange={setValue}
size="lg"
value={value}
/>
</div>
);
}

function DrivesOtherComponentsStory(
args: ColorSwatchPickerProps,
): React.JSX.Element {
const [value, setValue] = useState<ColorName>(args.value);

return (
<div style={{display: 'grid', gap: 16, justifyItems: 'start'}}>
<ColorSwatchPicker {...args} onChange={setValue} value={value} />
<div style={{display: 'flex', gap: 8}}>
<Tag color={value} label="North office" />
<Badge color={value} label="North office" />
</div>
</div>
);
}

const meta = {
title: 'Components/ColorSwatchPicker',
component: ColorSwatchPicker,
args: {
label: 'Office color',
description: 'Choose the named theme color used for this office.',
size: 'md',
value: 'blue',
},
argTypes: {
isDisabled: {control: 'boolean'},
size: {
control: {type: 'select'},
options: ['sm', 'md', 'lg'],
},
},
render: (args: ColorSwatchPickerProps): React.JSX.Element => (
<ColorSwatchPickerStory {...args} />
),
} satisfies Meta<ColorSwatchPickerProps>;

export default meta;
type Story = StoryObj<ColorSwatchPickerProps>;

export const Default: Story = {};

export const Sizes: Story = {
render: (args: ColorSwatchPickerProps): React.JSX.Element => (
<SizesStory {...args} />
),
};

export const Subset: Story = {
args: {
colors: ['red', 'orange', 'green', 'blue', 'purple'],
value: 'green',
},
};

/**
* Hovering a swatch grows it slightly and reveals a tooltip naming the color.
* The selected swatch carries an outer ring in its own color.
*/
export const HoverAndTooltips: Story = {
args: {
description: 'Hover a swatch to see its name and the grow effect.',
value: 'purple',
},
};

export const Disabled: Story = {
args: {
isDisabled: true,
},
};

export const Error: Story = {
args: {
status: {message: 'Choose an office color.', type: 'error'},
},
};

export const Required: Story = {
args: {
isRequired: true,
},
};

export const DrivesOtherComponents: Story = {
render: (args: ColorSwatchPickerProps): React.JSX.Element => (
<DrivesOtherComponentsStory {...args} />
),
};
Loading