diff --git a/README.md b/README.md index 9a6ff83..177724b 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,102 @@ # tailscale-ui-components Tailscale UI component library used by tailscale/corp/adminhttp/panel and tailscale/tailscale/client/web React projects. Not maintained for external use. + +## Development Setup + +### Prerequisites + +Ensure you have the following tools installed: + +- **Node.js 22.14.0** (use `nvm` to manage Node versions) + ```bash + nvm use 22.14.0 + ``` +- **Yarn 1.22.19** (package manager) + ```bash + npm install -g yarn@1.22.19 + ``` + +### Getting Started + +1. **Clone the repository** + ```bash + git clone https://github.com/tailscale/tailscale-ui-components.git + cd tailscale-ui-components + ``` + +2. **Install dependencies** + ```bash + yarn install + ``` + +3. **Start Storybook for development** + ```bash + yarn storybook + ``` + This will start Storybook on `http://localhost:6006` (or the next available port) + +### Available Scripts + +- `yarn storybook` - Start Storybook development server +- `yarn build-storybook` - Build Storybook for production +- `yarn build` - Build the component library for production +- `yarn test` - Run tests with Vitest + +### Environment Compatibility + +This project has been tested and works with: +- **Node.js 22.14.0** (recommended for team consistency) + +### Package Management + +This project uses **Yarn v1** as the package manager. The `yarn.lock` file should be committed, and `package-lock.json` should be ignored/removed if present. + +### Component Development + +Components are located in `src/components/` and follow these patterns: + +1. **OSS-ready structure** with proper style mappings +2. **Comprehensive TypeScript types** and exported constants +3. **Accessibility features** with proper ARIA attributes +4. **Storybook stories** for documentation and testing + +Example component structure: +``` +src/components/button/ +├── button.tsx # Main component +├── button.stories.tsx # Storybook stories +└── index.ts # Barrel export +``` + +### Styling Guidelines + +- Use the `.button`, `.input`, and other base CSS classes from `src/tailwind.css` +- Follow the established design tokens and color system +- Ensure components have rounded corners and consistent spacing +- Support both light and dark themes + +### Contributing + +When adding or modifying components: + +1. Ensure TypeScript strict mode compliance +2. Add comprehensive Storybook stories +3. Include proper accessibility attributes +4. Follow the established component patterns +5. Export components from `src/index.ts` + +### Troubleshooting + +**Node.js Version Issues:** +If you encounter build errors, ensure you're using Node.js 22.14.0: +```bash +node --version # Should output v22.14.0 +``` + +**Package Manager Issues:** +If dependencies fail to install, try: +```bash +rm -rf node_modules yarn.lock +yarn install +``` \ No newline at end of file diff --git a/src/components/button/button.tsx b/src/components/button/button.tsx index 1d5a165..53b7d8f 100644 --- a/src/components/button/button.tsx +++ b/src/components/button/button.tsx @@ -1,21 +1,97 @@ import cx from "classnames" import React, { HTMLProps } from "react" -import { LoadingDots } from "src/components/loading-dots/loading-dots" +import { LoadingDots } from "../loading-dots/loading-dots" + +export type ButtonVariant = "filled" | "minimal" +export type ButtonIntent = "base" | "primary" | "warning" | "danger" | "black" +export type ButtonSize = "xsmall" | "input" | "small" | "medium" | "large" + +export const BUTTON_VARIANTS = [ + "filled", + "minimal", +] as const satisfies readonly ButtonVariant[] + +export const BUTTON_INTENTS = [ + "base", + "primary", + "warning", + "danger", + "black", +] as const satisfies readonly ButtonIntent[] + +export const BUTTON_SIZES = [ + "xsmall", + "input", + "small", + "medium", + "large", +] as const satisfies readonly ButtonSize[] + +const BUTTON_FILLED_INTENT_STYLES: Record = { + base: "bg-gray-0 dark:bg-gray-700 border-gray-300 dark:border-gray-600 enabled:hover:bg-gray-100 dark:enabled:hover:bg-gray-600 enabled:hover:border-gray-300 dark:enabled:hover:border-gray-500 enabled:focus-visible:bg-gray-100 dark:enabled:focus-visible:bg-gray-600 enabled:focus-visible:border-gray-300 dark:enabled:focus-visible:border-gray-500 enabled:hover:text-gray-900 dark:enabled:hover:text-gray-300 disabled:border-gray-200 dark:disabled:border-gray-600/30 disabled:text-text-disabled", + primary: + "bg-blue-500 dark:bg-blue-600 border-blue-500 dark:border-blue-600 text-white enabled:hover:bg-blue-600 dark:enabled:hover:bg-blue-500 enabled:hover:border-blue-600 dark:enabled:hover:border-blue-500 enabled:focus-visible:bg-blue-600 dark:enabled:focus-visible:bg-blue-500 disabled:text-blue-50 disabled:bg-blue-300 dark:disabled:bg-blue-600 disabled:border-blue-300 dark:disabled:border-blue-600 dark:disabled:opacity-40", + danger: + "bg-red-400 dark:bg-red-500 border-red-400 dark:border-red-500 text-white enabled:hover:bg-red-500 dark:enabled:hover:bg-red-400 enabled:hover:border-red-500 dark:enabled:hover:border-red-400 enabled:focus-visible:outline-outline-focus-danger enabled:focus-visible:bg-red-500 dark:enabled:focus-visible:bg-red-400 disabled:text-red-50 disabled:bg-red-300 dark:disabled:bg-red-500 disabled:border-red-300 dark:disabled:border-red-500 dark:disabled:opacity-40", + warning: + "bg-yellow-300 dark:bg-yellow-400 border-yellow-300 dark:border-yellow-400 text-white enabled:hover:bg-yellow-400 dark:enabled:hover:bg-yellow-300 enabled:hover:border-yellow-400 dark:enabled:hover:border-yellow-300 enabled:focus-visible:outline-outline-focus-warning enabled:focus-visible:bg-yellow-400 dark:enabled:focus-visible:bg-yellow-300 disabled:text-yellow-50 disabled:bg-yellow-200 dark:disabled:bg-yellow-400 disabled:border-yellow-200 dark:disabled:border-yellow-400 dark:disabled:opacity-40", + black: + "bg-gray-800 border-gray-800 text-white enabled:hover:bg-gray-900 dark:enabled:hover:bg-gray-700 enabled:hover:border-gray-900 dark:enabled:hover:border-gray-700 disabled:opacity-75", +} + +const BUTTON_FILLED_ACTIVE_STYLES: Record = { + base: "enabled:bg-gray-200 dark:enabled:bg-gray-800 enabled:border-gray-300 dark:enabled:border-gray-700", + primary: "", + danger: "", + warning: "", + black: "", +} + +const BUTTON_MINIMAL_INTENT_STYLES: Record = { + base: "text-text-base enabled:focus-visible:bg-gray-100 dark:enabled:focus-visible:bg-gray-500/10 enabled:hover:bg-gray-100 dark:enabled:hover:bg-gray-500/10", + black: + "text-text-base enabled:focus-visible:bg-gray-100 dark:enabled:focus-visible:bg-gray-500/10 enabled:hover:bg-gray-100 dark:enabled:hover:bg-gray-500/10", + primary: + "text-text-primary enabled:focus-visible:bg-blue-0 dark:enabled:focus-visible:bg-blue-400/10 enabled:hover:bg-blue-0 dark:enabled:hover:bg-blue-400/10", + danger: + "text-text-danger enabled:focus-visible:bg-red-0 dark:enabled:focus-visible:bg-red-400/10 enabled:focus-visible:outline-outline-focus-danger enabled:hover:bg-red-0 dark:enabled:hover:bg-red-400/10", + warning: + "text-text-warning enabled:focus-visible:bg-orange-0 dark:enabled:focus-visible:bg-orange-400/10 enabled:focus-visible:outline-outline-focus-warning enabled:hover:bg-orange-0 dark:enabled:hover:bg-orange-400/10", +} + +const BUTTON_MINIMAL_ACTIVE_STYLES: Record = { + base: "enabled:bg-gray-200 border-gray-300", + black: "enabled:bg-gray-200 border-gray-300", + primary: "", + danger: "", + warning: "", +} + +const BUTTON_SIZE_STYLES: Record< + ButtonSize, + { height: string; padding?: string; text?: string } +> = { + xsmall: { height: "h-6" }, + input: { height: "h-9" }, + small: { height: "h-8 text-sm", padding: "px-2.5 text-sm" }, + medium: { height: "h-9", padding: "px-3" }, + large: { height: "h-10", padding: "px-4" }, +} export type ButtonProps = { type?: "button" | "submit" | "reset" - sizeVariant?: "xsmall" | "input" | "small" | "medium" | "large" + sizeVariant?: ButtonSize /** * variant is the visual style of the button. By default, this is a filled * button. For a less prominent button, use minimal. */ - variant?: Variant + variant?: ButtonVariant /** * intent describes the semantic meaning of the button's action. For * dangerous or destructive actions, use danger. For actions that should * be the primary focus, use primary. */ - intent?: Intent + intent?: ButtonIntent active?: boolean /** @@ -43,114 +119,142 @@ export type ButtonProps = { textAlign?: "center" | "left" } & HTMLProps -export type Variant = "filled" | "minimal" -export type Intent = "base" | "primary" | "warning" | "danger" | "black" - /** * Button is a clickable element that can be used to trigger actions. It can * have different visual styles and semantic meanings. + * + * @example + * ```tsx + * // Basic button + * + * + * // Primary button + * + * + * // Button with icon + * + * + * // Minimal button + * + * + * // Loading button + * + * ``` */ -export const Button = React.forwardRef((props, ref) => { - const { - className, - variant = "filled", - intent = "base", - sizeVariant = "medium", - disabled, - children, - loading, - active, - iconOnly, - prefixIcon, - suffixIcon, - textAlign, - ...rest - } = props - - const hasIcon = Boolean(prefixIcon || suffixIcon) - - return ( - - ) -}) \ No newline at end of file + { + "relative z-10": active === true, + }, + + className + )} + ref={ref} + type={type} + disabled={isDisabled} + aria-disabled={isDisabled} + aria-busy={loading} + {...rest} + > + {prefixIcon && ( + + )} + {loading && ( +