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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ node_modules
.next
dist
coverage
storybook-static
*.tsbuildinfo
.DS_Store
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ Before adding or changing a component backed by Base UI, read
- Keep pull request descriptions concise and limited to a summary. Do not add a
verification section.

## Component files

- Colocate component stories and tests with their source under
`packages/components/src` using `*.stories.tsx` and `*.test.tsx` filenames.
- Do not create separate top-level story or test directories for component
coverage.

## Color

Before adding or changing component colors, read
Expand Down
11 changes: 11 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,17 @@ Start the Next.js documentation site with:
pnpm dev
```

Start Storybook to develop components with interactive prop controls:

```bash
pnpm storybook
```

The app runs at `http://localhost:6006`. Colocate each component's
`*.stories.tsx` and `*.test.tsx` files with its source in
`packages/components/src`, and expose supported variants through Storybook
args.

## Making changes

Create a focused branch and keep component code, tests, and documentation in
Expand Down
32 changes: 32 additions & 0 deletions apps/storybook/.storybook/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { StorybookConfig } from "@storybook/react-vite";
import stylex from "@stylexjs/unplugin/vite";
import { fileURLToPath } from "node:url";
import { mergeConfig } from "vite";

const rootDir = fileURLToPath(new URL("../../..", import.meta.url));

const config: StorybookConfig = {
stories: ["../../../packages/components/src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
addons: ["@storybook/addon-a11y", "@storybook/addon-docs"],
framework: {
name: "@storybook/react-vite",
options: {},
},
core: {
disableTelemetry: true,
},
viteFinal: async (config) =>
mergeConfig(config, {
plugins: [
stylex({
useCSSLayers: true,
unstable_moduleResolution: {
type: "commonJS",
rootDir,
},
}),
],
}),
};

export default config;
21 changes: 21 additions & 0 deletions apps/storybook/.storybook/preview.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
@layer reset {
*,
*::before,
*::after {
box-sizing: border-box;
}

body {
margin: 0;
font-family: system-ui, sans-serif;
}

button,
input,
textarea,
select {
font: inherit;
}
}

@stylex;
19 changes: 19 additions & 0 deletions apps/storybook/.storybook/preview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { Preview } from "@storybook/react-vite";

import "./preview.css";

const preview: Preview = {
parameters: {
controls: {
expanded: true,
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
layout: "centered",
},
tags: ["autodocs"],
};

export default preview;
28 changes: 28 additions & 0 deletions apps/storybook/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"name": "@repo/storybook",
"version": "0.0.0",
"private": true,
"scripts": {
"dev": "storybook dev --port 6006 --no-open",
"build": "storybook build",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@alexcarpenter/components": "workspace:*",
"@stylexjs/stylex": "catalog:",
"react": "catalog:",
"react-dom": "catalog:"
},
"devDependencies": {
"@storybook/addon-a11y": "^10.5.5",
"@storybook/addon-docs": "^10.5.5",
"@storybook/react-vite": "^10.5.5",
"@stylexjs/unplugin": "^0.19.0",
"@types/node": "^24.13.3",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"storybook": "^10.5.5",
"typescript": "catalog:",
"vite": "^7.3.6"
}
}
8 changes: 8 additions & 0 deletions apps/storybook/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"jsx": "react-jsx",
"types": ["node"]
},
"include": [".storybook/**/*.ts"]
}
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
"scripts": {
"dev": "pnpm --filter docs dev",
"build": "pnpm --filter docs build",
"storybook": "pnpm --filter @repo/storybook dev",
"storybook:build": "pnpm --filter @repo/storybook build",
"prepare": "husky",
"changeset": "changeset",
"changeset:status": "changeset status",
Expand Down
5 changes: 4 additions & 1 deletion packages/components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,14 @@
},
"files": [
"src",
"!src/**/*.stories.*",
"!src/**/*.test.*",
"README.md"
],
"type": "module",
"sideEffects": false,
"exports": {
"./button": "./src/button/index.tsx",
"./button": "./src/button/index.ts",
"./tokens.stylex": "./src/tokens.stylex.ts",
"./package.json": "./package.json"
},
Expand All @@ -34,6 +36,7 @@
},
"devDependencies": {
"@radix-ui/colors": "3.0.0",
"@storybook/react-vite": "^10.5.5",
"@stylexjs/stylex": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
Expand Down
55 changes: 55 additions & 0 deletions packages/components/src/button/button.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { Meta, StoryObj } from "@storybook/react-vite";

import { Button } from "./index";

const meta = {
title: "Components/Button",
component: Button,
parameters: {
layout: "centered",
},
args: {
children: "Button",
color: "primary",
disabled: false,
shape: "default",
size: "md",
variant: "filled",
},
argTypes: {
children: {
control: "text",
},
color: {
control: "select",
options: ["primary", "neutral", "negative"],
},
disabled: {
control: "boolean",
},
shape: {
control: "select",
options: ["default", "square", "circle"],
},
size: {
control: "select",
options: ["sm", "md", "lg"],
},
variant: {
control: "select",
options: ["filled", "outline", "ghost", "link"],
},
},
} satisfies Meta<typeof Button>;

export default meta;

type Story = StoryObj<typeof meta>;

export const Playground: Story = {};

export const Disabled: Story = {
args: {
disabled: true,
},
};
74 changes: 74 additions & 0 deletions packages/components/src/button/button.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { createRef } from "react";
import * as stylex from "@stylexjs/stylex";
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

import { Button } from "./index";
import type { ButtonState } from "./index";

const styles = stylex.create({
rendered: {
display: "inline-flex",
},
});

describe("Button", () => {
it("renders an accessible native button", () => {
render(<Button>Save changes</Button>);

expect(screen.getByRole("button", { name: "Save changes" })).toHaveAttribute("type", "button");
});

it("handles clicks and suppresses them when disabled", async () => {
const user = userEvent.setup();
const onClick = vi.fn();
const { rerender } = render(<Button onClick={onClick}>Submit</Button>);

await user.click(screen.getByRole("button"));
expect(onClick).toHaveBeenCalledTimes(1);

rerender(
<Button disabled onClick={onClick}>
Submit
</Button>,
);
await user.click(screen.getByRole("button"));
expect(onClick).toHaveBeenCalledTimes(1);
});

it("forwards state-aware sx classes through the render prop", () => {
const sx = vi.fn((_state: ButtonState) => styles.rendered);

render(
<Button nativeButton={false} render={<span />} sx={sx}>
Save
</Button>,
);

expect(screen.getByRole("button", { name: "Save" })).toHaveClass(
stylex.props(styles.rendered).className as string,
);
expect(sx).toHaveBeenCalledWith({
color: "primary",
disabled: false,
shape: "default",
size: "md",
variant: "filled",
});
});

it("forwards native props and refs", () => {
const ref = createRef<HTMLButtonElement>();
render(
<Button ref={ref} name="intent" value="save">
Save
</Button>,
);

const button = screen.getByRole("button");
expect(button).toHaveAttribute("name", "intent");
expect(button).toHaveAttribute("value", "save");
expect(ref.current).toBe(button);
});
});
45 changes: 45 additions & 0 deletions packages/components/src/button/button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"use client";

import { Button as BaseButton } from "@base-ui/react/button";
import * as stylex from "@stylexjs/stylex";

import { stylexRenderProps, type StyleXRenderStyle } from "../utils/stylex-render-props";

type ButtonColor = "primary" | "neutral" | "negative";
type ButtonShape = "default" | "square" | "circle";
type ButtonSize = "sm" | "md" | "lg";
type ButtonVariant = "filled" | "outline" | "ghost" | "link";

export type ButtonState = BaseButton.State & {
color: ButtonColor;
shape: ButtonShape;
size: ButtonSize;
variant: ButtonVariant;
};

export type ButtonProps = Omit<BaseButton.Props, "className" | "style"> & {
sx?: StyleXRenderStyle<ButtonState>;
size?: ButtonSize;
variant?: ButtonVariant;
color?: ButtonColor;
shape?: ButtonShape;
};

export function Button({
color = "primary",
shape = "default",
size = "md",
variant = "filled",
sx,
...props
}: ButtonProps) {
const renderProps = stylexRenderProps<BaseButton.State>(styles.base, (state) =>
typeof sx === "function" ? sx({ ...state, color, shape, size, variant }) : sx,
);

return <BaseButton {...props} {...renderProps} />;
}

const styles = stylex.create({
base: {},
});
2 changes: 2 additions & 0 deletions packages/components/src/button/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { Button } from "./button";
export type { ButtonProps, ButtonState } from "./button";
Loading