diff --git a/.gitignore b/.gitignore index 772198e37..f9e56650d 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ vite.config.ts.timestamp* .yarn/* !.yarn/releases !.yarn/plugins +# Temporary documentation and planning files +.tmp/ +tmp/ diff --git a/.prettierignore b/.prettierignore index 1758950ad..e69de29bb 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1 +0,0 @@ -src/styles/types.ts diff --git a/.storybook/main.ts b/.storybook/main.ts index 5182f9b16..4289f120b 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -1,7 +1,7 @@ import type { StorybookConfig } from "@storybook/react-vite"; const config: StorybookConfig = { core: { - disableTelemetry: true + disableTelemetry: true, }, stories: [ "../src/Introduction.mdx", @@ -9,8 +9,12 @@ const config: StorybookConfig = { "../src/**/*.stories.@(js|jsx|ts|tsx)", ], - addons: ["@storybook/addon-links", //"@storybook/addon-interactions", - "storybook-addon-pseudo-states", "@storybook/addon-a11y", "@storybook/addon-docs"], + addons: [ + "@storybook/addon-links", //"@storybook/addon-interactions", + "storybook-addon-pseudo-states", + "@storybook/addon-a11y", + "@storybook/addon-docs", + ], framework: { name: "@storybook/react-vite", @@ -32,32 +36,42 @@ const config: StorybookConfig = { }, }, - async viteFinal(config, { configType }) { + viteFinal: async (config, { configType }) => { // Workaround for Storybook 10.0.7 bug where MDX files generate file:// imports // See: https://github.com/storybookjs/storybook/issues (mdx-react-shim resolution) config.plugins = config.plugins || []; config.plugins.push({ - name: 'fix-storybook-mdx-shim', + name: "fix-storybook-mdx-shim", resolveId(source) { // Intercept the malformed file:// URL and resolve to the correct package - if (source.includes('mdx-react-shim')) { - return this.resolve('@mdx-js/react', undefined, { skipSelf: true }); + if (source.includes("mdx-react-shim")) { + return this.resolve("@mdx-js/react", undefined, { skipSelf: true }); } return null; }, }); // Suppress Rollup warnings for production builds - if (configType === 'PRODUCTION') { + if (configType === "PRODUCTION") { config.build = config.build || {}; config.build.rollupOptions = config.build.rollupOptions || {}; const originalOnWarn = config.build.rollupOptions.onwarn; config.build.rollupOptions.onwarn = (warning, warn) => { - if (warning.message?.includes('mdx-react-shim')) return; - originalOnWarn ? originalOnWarn(warning, warn) : warn(warning); + if (warning.message?.includes("mdx-react-shim")) { + return; + } + if (originalOnWarn) { + originalOnWarn(warning, warn); + } else { + warn(warning); + } }; } + // Ensure CSS modules specificity is correct + config.build = config.build || {}; + config.build.cssCodeSplit = false; + return config; }, }; diff --git a/.storybook/preview.module.scss b/.storybook/preview.module.scss new file mode 100644 index 000000000..9fc1283cb --- /dev/null +++ b/.storybook/preview.module.scss @@ -0,0 +1,22 @@ +.cuiThemeBlock { + position: absolute; + inset: 0; + min-height: fill-available; + + overflow: auto; + padding: 1rem; + box-sizing: border-box; + background: tokens.$clickStorybookGlobalBackground; + + &.cuiLeft { + left: 0; + } + + &.cuiRight { + left: 50vw; + } + + &.cuiFill { + left: 0; + } +} diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index c5e9bd1ca..db8c0e98a 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -1,23 +1,36 @@ import React from "react"; import type { Preview } from "@storybook/react-vite"; import { Decorator } from "@storybook/react-vite"; -import styled from "styled-components"; import { themes } from "storybook/theming"; -import ClickUIProvider from "../src/theme/ClickUIProvider/ClickUIProvider"; +import { ClickUIProvider } from "@/theme/ClickUIProvider"; +import clsx from "clsx"; +import styles from "./preview.module.scss"; -const ThemeBlock = styled.div<{ $left?: boolean; $bfill?: boolean }>( - ({ $left, $bfill: fill, theme }) => ` - position: absolute; - top: 0.5rem; - left: ${$left || fill ? 0 : "50vw"}; - right: 0; - height: fit-content; - bottom: 0; - overflow: auto; - padding: 1rem; - box-sizing: border-box; - background: ${theme.click.storybook.global.background}; - ` +interface ThemeBlockProps { + left?: boolean; + fill?: boolean; + children: React.ReactNode; +} + +const ThemeBlock: React.FC = ({ + left, + fill, + theme = "light", + children, +}) => ( +
+ {children} +
); export const globalTypes = { @@ -26,27 +39,34 @@ export const globalTypes = { description: "Global theme for components", defaultValue: "dark", toolbar: { - // The icon for the toolbar item icon: "circlehollow", - // Array of options items: [ - { value: "dark", icon: "moon", title: "dark" }, - { value: "light", icon: "sun", title: "light" }, + { value: "light", icon: "sun", title: "Light" }, + { value: "dark", icon: "moon", title: "Dark" }, + { value: "system", icon: "browser", title: "System" }, ], - // Property that specifies if the name of the item will be displayed showName: true, + dynamicTitle: true, }, }, }; const withTheme: Decorator = (StoryFn, context) => { const parameters = context.parameters; - const theme = parameters?.theme || context.globals.theme; + const theme = parameters?.theme || context.globals.theme || "light"; + return ( - + @@ -81,13 +101,9 @@ const preview: Preview = { }, docs: { theme: themes.dark, - codePanel: true + codePanel: true, }, }, - argTypes: { - // Hide children prop from docs table - it doesn't serialize well as a control - children: { table: { disable: true } }, - }, }; export const decorators = [withTheme]; diff --git a/.stylelintignore b/.stylelintignore new file mode 100644 index 000000000..8453cd6bf --- /dev/null +++ b/.stylelintignore @@ -0,0 +1,10 @@ +# Ignore files not yet migrated +src/App.module.scss +src/theme/ + +# Ignore third-party or generated files +src/styles/tokens-light-dark.scss +node_modules +dist +build +*.min.css diff --git a/.stylelintrc.json b/.stylelintrc.json new file mode 100644 index 000000000..6dfd95869 --- /dev/null +++ b/.stylelintrc.json @@ -0,0 +1,76 @@ +{ + "extends": ["stylelint-config-standard-scss"], + "plugins": ["stylelint-scss"], + "customSyntax": "postcss-scss", + "rules": { + "scss/at-rule-no-unknown": [ + true, + { + "ignoreAtRules": ["use", "include", "mixin", "function", "return", "each", "if", "else"] + } + ], + "scss/dollar-variable-pattern": null, + "scss/at-mixin-pattern": null, + "scss/at-function-pattern": null, + "scss/percent-placeholder-pattern": null, + "selector-class-pattern": null, + "custom-property-pattern": null, + "keyframes-name-pattern": null, + "scss/at-extend-no-missing-placeholder": null, + "no-descending-specificity": null, + "scss/load-no-partial-leading-underscore": null, + "color-function-notation": null, + "media-feature-range-notation": null, + "alpha-value-notation": null, + "property-no-unknown": [ + true, + { + "ignoreProperties": ["composes"] + } + ], + "function-no-unknown": [ + true, + { + "ignoreFunctions": [ + "tokens", + "var", + "calc", + "min", + "max", + "clamp", + "url", + "rgba", + "rgb", + "hsl", + "hsla", + "linear-gradient", + "radial-gradient" + ] + } + ], + "value-keyword-case": [ + "lower", + { + "ignoreProperties": ["composes"] + } + ], + "scss/operator-no-newline-after": null, + "scss/operator-no-unspaced": null, + "declaration-empty-line-before": null, + "rule-empty-line-before": null, + "at-rule-empty-line-before": null, + "comment-empty-line-before": null, + "scss/double-slash-comment-empty-line-before": null, + "scss/comment-no-empty": null, + "no-invalid-position-at-import-rule": null, + "no-duplicate-selectors": null, + "selector-pseudo-class-no-unknown": [ + true, + { + "ignorePseudoClasses": ["global"] + } + ], + "property-no-deprecated": null, + "declaration-property-value-keyword-no-deprecated": null + } +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 2c63c0851..1df65ee1a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,2 +1,19 @@ { + // TypeScript: Prioritize source files over declaration files + "typescript.preferences.autoImportFileExcludePatterns": [ + "**/*.scss.d.ts", + "**/node_modules/**" + ], + + // Hide generated .scss.d.ts files from search and file explorer + "files.exclude": { + "**/*.scss.d.ts": true + }, + + // Search settings to exclude generated files + "search.exclude": { + "**/*.scss.d.ts": true, + "**/node_modules": true, + "**/dist": true + } } diff --git a/README.md b/README.md index 6c54e8bbf..1e6e2094b 100644 --- a/README.md +++ b/README.md @@ -6,40 +6,176 @@ You can find the official docs for the Click UI design system and component libr Click UI has been tested in NextJS, Gatsby, and Vite. If you run into problems using it in your app, please create an issue and our team will try to answer. -1. Navigate to your app's route and run - `npm i @clickhouse/click-ui` - or - `yarn add @clickhouse/click-ui` -2. Make sure to wrap your application in the Click UI `ClickUIProvider`, without doing this, you may run into issues with styled-components. Once that's done, you'll be able to import the individual components that you want to use on each page. Here's an example of an `App.tsx` in NextJS. +### Quick Start + +1. **Install Click UI** + ```bash + npm install @clickhouse/click-ui + ``` + +2. **Import the required styles** + + Add these imports at the top of your main application file (before other styles): + + **Option A: Simple (Recommended for most projects)** + ```typescript + // All-in-one: Includes both component styles and default theme + import '@clickhouse/click-ui/cui.css'; + ``` + + **Option B: Granular (For custom theming)** + ```typescript + // Component styles only + import '@clickhouse/click-ui/cui-components.css'; + + // Default theme (light + dark with automatic switching) + import '@clickhouse/click-ui/cui-default-theme.css'; + + // OR import your custom theme instead: + // import './my-custom-theme.css'; + ``` + + **Where to import:** + - **Next.js App Router**: Add to your root `layout.tsx` + - **Next.js Pages Router**: Add to `pages/_app.tsx` + - **Gatsby**: Add to `gatsby-browser.js` + - **Vite/React**: Add to `main.tsx` or `App.tsx` + +3. **Wrap your app with ClickUIProvider** + + ```typescript + import '@clickhouse/click-ui/cui.css'; + import { ClickUIProvider, Text, Title } from '@clickhouse/click-ui' + + function App() { + return ( + + Click UI Example + Welcome to Click UI! + + ) + } + + export default App + ``` + +### Customizing Your Theme + +Click UI supports extensive theming through a CLI-based approach: + +#### Step 1: Create a config file + +```bash +npx click-ui init +``` + +This creates a `click-ui.config.ts` file in your project root. + +#### Step 2: Customize your theme ```typescript -import { ClickUIProvider, Text, ThemeName, Title, Switch } from '@clickhouse/click-ui' -import { useState } from 'react' - -function App() { - const [theme, setTheme] = useState('dark') - - const toggleTheme = () => { - theme === 'dark' ? setTheme('light') : setTheme('dark') - } - - return ( - - toggleTheme()} - label="Dark mode" - /> - - Click UI Example - Welcome to Click UI. Get started here. - - ) +// click-ui.config.ts +export default { + // Light mode theme tokens + theme: { + button: { + color: { + primary: { + 'background-default': '#FF6B00', + 'background-hover': '#FF8533', + } + } + } + }, + + // Dark mode overrides + dark: { + button: { + color: { + primary: { + 'background-default': '#FF8533', + 'background-hover': '#FFA366', + } + } + } + }, + + // Runtime configuration (optional) + storageKey: 'my-app-theme', + tooltipConfig: { delayDuration: 200 } } +``` + +#### Step 3: Generate your custom theme CSS -export default App +```bash +npx click-ui generate ``` +This creates `cui-custom-theme.css` in your `public/` folder. + +#### Step 4: Import your custom theme instead of the default + +```typescript +import '@clickhouse/click-ui/cui-components.css'; // Component styles only +import './public/cui-custom-theme.css'; // Your custom theme +import { ClickUIProvider } from '@clickhouse/click-ui' +``` + +**Resources:** +- **[CLI Theme Generation](CLI_THEME_GENERATION.md)** - Complete CLI documentation +- **[Theme System](src/theme/index.md)** - Runtime theming, hooks, and theme switching +- **[Build-Time Configuration](BUILD_TIME_CONFIG_CLICK_UI.md)** - Theme config API reference + +--- + +## How It Works + +Click UI uses a **pre-built CSS approach** for optimal performance: + +1. **CSS Files Generated at Build Time** + - Default theme included in the library (~10 KB) + - Custom themes generated via CLI + - Uses CSS `light-dark()` function for automatic theme switching + +2. **Minimal Runtime JavaScript** + - Provider only sets `data-theme` attribute (~600 bytes) + - Zero runtime CSS generation + - Instant theme switching via CSS attributes + +3. **Perfect Tree-Shaking** + - Import only the components you use + - Unused CSS automatically eliminated by your bundler + - Optimal bundle size + +### Theme Switching + +Themes are controlled via HTML attributes and CSS custom properties: + +```html + + + + + + + + +``` + +All theme tokens are available as CSS variables with the `--click` prefix: + +```css +.my-component { + background: var(--click-global-color-background-default); + color: var(--click-global-color-text-default); +} +``` + +The provider automatically sets the theme attribute based on user preference and system settings. + +--- + ## To develop this library locally šŸš€ 1. Clone this repo, cd into the `click-ui` directory diff --git a/bin/README.md b/bin/README.md new file mode 100644 index 000000000..c5e21108e --- /dev/null +++ b/bin/README.md @@ -0,0 +1,178 @@ +# Click UI CLI + +This directory contains the Click UI command-line interface for theme customization. + +## Quick Start + +```bash +# Initialize a config file +npx click-ui init + +# Generate custom theme CSS +npx click-ui generate + +# Get help +npx click-ui --help +``` + +## Commands + +### `click-ui init` + +Creates a `click-ui.config.ts` file in your project root with default configuration. + +**Options:** +- `-f, --format ` - Config format: `js` or `ts` (default: `ts`) +- `--force` - Overwrite existing config file +- `-h, --help` - Display help + +**Example:** +```bash +npx click-ui init +npx click-ui init --format js +npx click-ui init --force +``` + +### `click-ui generate` + +Generates custom theme CSS from your `click-ui.config.ts` file. + +**Options:** +- `-o, --output ` - Output file path (default: `public/cui-custom-theme.css`) +- `-v, --verbose` - Show detailed output +- `-w, --watch` - Watch for config changes and regenerate +- `-h, --help` - Display help + +**Example:** +```bash +npx click-ui generate +npx click-ui generate --output src/theme.css +npx click-ui generate --watch +``` + +## Configuration + +See [click-ui.config.example.ts](./click-ui.config.example.ts) for a complete example configuration. + +### Basic Structure + +```typescript +import type { ThemeConfig } from '@clickhouse/click-ui/theme'; + +const config: ThemeConfig = { + // Light mode theme + theme: { + global: { + color: { + brand: '#FF6B6B', + } + } + }, + + // Dark mode overrides + dark: { + global: { + color: { + brand: '#FF8A80', + } + } + }, + + // Runtime configuration + storageKey: 'my-app-theme', +}; + +export default config; +``` + +## Workflow + +1. **Create config:** + ```bash + npx click-ui init + ``` + +2. **Customize theme** in `click-ui.config.ts`: + ```typescript + theme: { + button: { + primary: { + background: { default: '#FF6B6B' } + } + } + } + ``` + +3. **Generate CSS:** + ```bash + npx click-ui generate + ``` + +4. **Import in your app:** + ```typescript + import '@clickhouse/click-ui/cui.css'; + import './public/cui-custom-theme.css'; // Your custom theme + import { ClickUIProvider } from '@clickhouse/click-ui'; + // OR for simple setup without custom theme: + // import '@clickhouse/click-ui/cui.css'; + + function App() { + return ( + + {/* Your app */} + + ); + } + ``` + +## File Structure + +``` +bin/ +ā”œā”€ā”€ click-ui.js # CLI entry point +ā”œā”€ā”€ click-ui.config.example.ts # Example configuration +ā”œā”€ā”€ commands/ +│ ā”œā”€ā”€ init.js # Creates config file +│ ā”œā”€ā”€ generate.js # Generates theme CSS +│ └── build-default-theme.ts # Internal: builds library's default theme +└── utils/ + └── css-generator.js # CSS generation utilities +``` + +## TypeScript Support + +Full TypeScript support with autocomplete for theme configuration: + +```typescript +import type { ThemeConfig } from '@clickhouse/click-ui/theme'; + +const config: ThemeConfig = { + theme: { + // TypeScript autocomplete works here! ✨ + } +}; +``` + +## CSS Output + +The CLI generates CSS using `light-dark()` functions for automatic theme switching: + +```css +:root { + color-scheme: light dark; + --click-button-color-primary-background-default: light-dark(#FF6B6B, #FF8A80); +} +``` + +This allows the browser to automatically switch between light and dark values based on the user's system preference or your theme setting. + +## Documentation + +For more information: +- [Main README](../README.md) - Getting started +- [Theme Documentation](../src/theme/index.md) - Theme configuration guide +- [GitHub Repository](https://github.com/ClickHouse/click-ui) + +## License + +Apache-2.0 diff --git a/bin/click-ui.config.example.ts b/bin/click-ui.config.example.ts new file mode 100644 index 000000000..435be6f33 --- /dev/null +++ b/bin/click-ui.config.example.ts @@ -0,0 +1,70 @@ +import type { ThemeConfig } from "@clickhouse/click-ui/theme"; + +const config: ThemeConfig = { + storageKey: "click-ui-theme", + + // Light mode theme (default) + theme: { + global: { + color: { + brand: "#FF6B6B", + background: { + default: "#FFFFFF", + }, + }, + }, + button: { + space: { + x: "1.5rem", + y: "0.75rem", + }, + radii: { + all: "0.5rem", + }, + primary: { + background: { + default: "#FF6B6B", + hover: "#FF5252", + }, + }, + }, + }, + + // Dark mode overrides - if not defined, theme values are used for dark mode too + dark: { + global: { + color: { + background: { + default: "#0D1117", + }, + text: { + default: "#F0F6FC", + }, + }, + }, + button: { + primary: { + background: { + default: "#FF8A80", + hover: "#FF7043", + }, + }, + }, + }, + + // Tooltip configuration (optional) + // tooltipConfig: { + // delayDuration: 100, + // skipDelayDuration: 300, + // disableHoverableContent: false, + // }, + + // Toast configuration (optional) + // toastConfig: { + // duration: 4000, + // swipeDirection: "right", + // swipeThreshold: 50, + // }, +}; + +export default config; diff --git a/bin/click-ui.js b/bin/click-ui.js new file mode 100755 index 000000000..643e89f73 --- /dev/null +++ b/bin/click-ui.js @@ -0,0 +1,97 @@ +#!/usr/bin/env node + +import { initCommand } from './commands/init.js'; +import { generateCommand } from './commands/generate.js'; + +const args = process.argv.slice(2); +const command = args[0]; + +if (command === 'init') { + const options = { + format: 'ts', + force: false + }; + + // Parse options + for (let i = 1; i < args.length; i++) { + if (args[i] === '-f' || args[i] === '--format') { + options.format = args[i + 1]; + i++; + } else if (args[i] === '--force') { + options.force = true; + } else if (args[i] === '-h' || args[i] === '--help') { + console.log(` +Usage: @clickhouse/click-ui init [options] + +Initialize Click UI configuration file + +Options: + -f, --format Config format (js or ts) (default: "ts") + --force Overwrite existing config file + -h, --help Display help for command + `); + process.exit(0); + } + } + + initCommand(options); +} else if (command === 'generate') { + const options = { + output: null, + verbose: false, + watch: false + }; + + // Parse options + for (let i = 1; i < args.length; i++) { + if (args[i] === '-o' || args[i] === '--output') { + options.output = args[i + 1]; + i++; + } else if (args[i] === '-v' || args[i] === '--verbose') { + options.verbose = true; + } else if (args[i] === '-w' || args[i] === '--watch') { + options.watch = true; + } else if (args[i] === '-h' || args[i] === '--help') { + console.log(` +Usage: @clickhouse/click-ui generate [options] + +Generate custom theme CSS from click-ui.config.ts/js + +Options: + -o, --output Output file path (default: "public/cui-custom-theme.css") + -v, --verbose Show detailed output + -w, --watch Watch for config changes and regenerate + -h, --help Display help for command + +Example: + click-ui generate + click-ui generate --output src/theme.css + click-ui generate --watch + `); + process.exit(0); + } + } + + generateCommand(options); +} else if (command === '--version' || command === '-V') { + console.log('0.0.234'); +} else if (command === '--help' || command === '-h' || !command) { + console.log(` +Usage: @clickhouse/click-ui [options] [command] + +CLI for ClickHouse Click UI + +Options: + -V, --version Output the version number + -h, --help Display help for command + +Commands: + init [options] Initialize Click UI configuration file + generate [options] Generate custom theme CSS from config + help [command] Display help for command + `); +} else { + console.error(`Unknown command: ${command}`); + console.log('Run "@clickhouse/click-ui --help" for usage information.'); + process.exit(1); +} diff --git a/bin/commands/build-default-theme.ts b/bin/commands/build-default-theme.ts new file mode 100644 index 000000000..63f06719e --- /dev/null +++ b/bin/commands/build-default-theme.ts @@ -0,0 +1,48 @@ +#!/usr/bin/env node + +/** + * Generate theme-default.css with light-dark() functions + * This runs during library build to create pre-compiled CSS + */ + +import * as fs from "fs"; +import * as path from "path"; +import { getBaseTheme } from "../../src/theme/utils"; +import { generateLightDarkVariables, generateThemeOverrides } from "../../src/theme/utils/css-generator"; +import { buildCSSOutput } from "../../src/theme/utils/css-builder"; + +// Load light and dark base themes from tokens +const lightTheme = getBaseTheme("light"); +const darkTheme = getBaseTheme("dark"); + +// Debug: Check if themes loaded correctly +console.log("\nšŸ” Debug Info:"); +console.log(`Light theme loaded: ${Object.keys(lightTheme).length} keys`); +console.log(`Dark theme loaded: ${Object.keys(darkTheme).length} keys`); +console.log(`Light accordion color: ${lightTheme?.click?.accordion?.color?.default?.label?.default}`); +console.log(`Dark accordion color: ${darkTheme?.click?.accordion?.color?.default?.label?.default}`); + +// Generate CSS using the same logic as injectThemeStyles() +const lightDarkVars = generateLightDarkVariables(lightTheme, darkTheme); +const themeOverrides = generateThemeOverrides(lightTheme, darkTheme); + +// Build CSS using shared builder +const css = buildCSSOutput(lightDarkVars, themeOverrides, { + headerComment: '/* Click UI Default Theme */\n/* Generated during library build - DO NOT EDIT MANUALLY */\n/* Uses light-dark() CSS function for automatic theme switching */\n' +}); + +// Ensure output directory exists +const outputDir = path.join(process.cwd(), "src", "styles"); +if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); +} + +// Write the CSS file +const outputPath = path.join(outputDir, "cui-default-theme.css"); +fs.writeFileSync(outputPath, css, "utf-8"); + +console.log(`āœ… Generated cui-default-theme.css (${css.length} bytes)`); +console.log(` Location: ${outputPath}`); +console.log(` Light vars: ${Object.keys(lightDarkVars).length}`); +console.log(` Light overrides: ${Object.keys(themeOverrides.light).length}`); +console.log(` Dark overrides: ${Object.keys(themeOverrides.dark).length}`); diff --git a/bin/commands/generate.js b/bin/commands/generate.js new file mode 100644 index 000000000..b2e016569 --- /dev/null +++ b/bin/commands/generate.js @@ -0,0 +1,117 @@ +#!/usr/bin/env node + +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { pathToFileURL } from 'url'; +import { findConfigFile } from '../../src/theme/utils/find-config.js'; +import { loadConfig, validateConfig } from '../utils/config-loader.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +/** + * Generate custom theme CSS from click-ui.config.ts/js + */ +export async function generateCommand(options = {}) { + const cwd = process.cwd(); + const outputPath = options.output || path.join(cwd, 'public', 'cui-custom-theme.css'); + const verbose = options.verbose || false; + + console.log('šŸŽØ Click UI Theme Generator\n'); + + try { + // Find config file + const configFile = findConfigFile(cwd); + + if (!configFile) { + console.log('ā„¹ļø No click-ui.config.ts/js found'); + console.log(' Run "click-ui init" to create a config file'); + process.exit(0); + } + + if (verbose) { + console.log(`šŸ“„ Found config: ${path.relative(cwd, configFile)}`); + } + + // Load config + const config = await loadConfig(configFile); + + if (!validateConfig(config)) { + console.log('āš ļø Config file exists but has no theme configuration'); + console.log(' Add "theme" or "dark" properties to customize your theme'); + process.exit(0); + } + + // Generate CSS + const css = await generateCSS(config); + + // Ensure output directory exists + const outputDir = path.dirname(outputPath); + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + if (verbose) { + console.log(`šŸ“ Created directory: ${path.relative(cwd, outputDir)}`); + } + } + + // Write CSS file + fs.writeFileSync(outputPath, css, 'utf-8'); + + console.log('āœ… Generated custom theme CSS'); + console.log(` Location: ${path.relative(cwd, outputPath)}`); + console.log(` Size: ${(css.length / 1024).toFixed(2)} KB\n`); + + console.log('šŸ“ Next steps:'); + console.log(' 1. Import the CSS in your app:'); + console.log(` import '${path.relative(cwd, outputPath).replace(/\\/g, '/')}';`); + console.log(' 2. Or add to your HTML:'); + console.log(` `); + + } catch (error) { + console.error('āŒ Failed to generate theme CSS:', error.message); + if (verbose) { + console.error(error.stack); + } + process.exit(1); + } +} + +/** + * Generate CSS from config + */ +async function generateCSS(config) { + // Import the CSS generation function from bin/utils + const cssGeneratorPath = path.join(__dirname, '../utils/css-generator.js'); + + if (!fs.existsSync(cssGeneratorPath)) { + throw new Error('CSS generator module not found at ' + cssGeneratorPath); + } + + const { generateThemeCSS } = await import(pathToFileURL(cssGeneratorPath).href); + + // Get full config and generate CSS + const fullConfig = config.default || config; + const css = generateThemeCSS(fullConfig); + + if (!css || css.length === 0) { + throw new Error('No CSS generated. Please check your theme configuration.'); + } + + return css; +} + +/** + * Split config into build-time and runtime + */ +function splitConfig(fullConfig) { + const { theme, dark, ...runtimeConfig } = fullConfig; + + return { + buildTimeConfig: { + ...(theme && { theme }), + ...(dark && { dark }), + }, + runtimeConfig, + }; +} diff --git a/bin/commands/init.js b/bin/commands/init.js new file mode 100644 index 000000000..d6c89b5d0 --- /dev/null +++ b/bin/commands/init.js @@ -0,0 +1,110 @@ +#!/usr/bin/env node + +import fs from "fs"; +import path from "path"; + +const CONFIG_BODY = ` // Optional: Customize the storage key for theme persistence + // storageKey: 'click-ui-theme', + + // Optional: Customize light mode theme + // theme: { + // global: { + // color: { + // brand: '#FFCC00', + // background: { + // default: '#FFFFFF' + // } + // } + // }, + // button: { + // space: { + // x: '1rem', + // y: '0.5rem' + // }, + // radii: { + // all: '0.375rem' + // } + // } + // }, + + // Optional: Dark mode overrides + // If not defined, theme values are used for dark mode too + // dark: { + // global: { + // color: { + // background: { + // default: '#0D1117' + // }, + // text: { + // default: '#F0F6FC' + // } + // } + // } + // }, + + // Optional: Tooltip configuration + // tooltipConfig: { + // delayDuration: 100, + // skipDelayDuration: 300, + // disableHoverableContent: false, + // }, + + // Optional: Toast configuration + // toastConfig: { + // duration: 4000, + // swipeDirection: 'right', + // swipeThreshold: 50, + // },`; + +const CONFIG_TEMPLATES = { + ts: `import type { ThemeConfig } from '@clickhouse/click-ui/theme'; + +const config: ThemeConfig = { +${CONFIG_BODY} +}; + +export default config; +`, + js: `/** @type {import('@clickhouse/click-ui/theme').ThemeConfig} */ +const config = { +${CONFIG_BODY} +}; + +export default config; +` +}; + +export const initCommand = options => { + const format = options.format === "js" ? "js" : "ts"; + const filename = `click-ui.config.${format}`; + const targetPath = path.join(process.cwd(), filename); + const configExt = format === "ts" ? "ts" : "js"; + + // Check if config already exists + if (fs.existsSync(targetPath) && !options.force) { + console.error(`āŒ ${filename} already exists. Use --force to overwrite.`); + process.exit(1); + } + + // Write config file + try { + fs.writeFileSync(targetPath, CONFIG_TEMPLATES[format], "utf-8"); + console.log(`āœ… Created ${filename}\n`); + console.log("šŸ“ Next steps:\n"); + console.log(` 1. Customize your theme in ${filename}\n`); + console.log(" 2. Generate your custom theme CSS:\n"); + console.log(" npx click-ui generate\n"); + console.log(" 3. Import the generated CSS in your app:\n"); + console.log(" import '@clickhouse/click-ui/style.css';"); + console.log(" import './public/cui-custom-theme.css'; // Your custom theme\n"); + console.log(" 4. Use ClickUIProvider in your app:\n"); + console.log(" import { ClickUIProvider } from '@clickhouse/click-ui';\n"); + console.log(" "); + console.log(" {/* Your app */}"); + console.log(" \n"); + console.log(" šŸŽØ Your custom theme will be applied!"); + } catch (error) { + console.error(`āŒ Failed to create config file: ${error.message}`); + process.exit(1); + } +}; diff --git a/bin/utils/config-loader.ts b/bin/utils/config-loader.ts new file mode 100644 index 000000000..762f4d163 --- /dev/null +++ b/bin/utils/config-loader.ts @@ -0,0 +1,61 @@ +import { pathToFileURL } from 'url'; + +/** + * Load Click UI configuration file + * Handles TypeScript, JavaScript, ESM, and CommonJS formats + * + * @param configFile - Full path to config file + * @returns Loaded configuration object + * @throws Error if config cannot be loaded or TypeScript file without tsx + * + * @example + * ```typescript + * const config = await loadConfig('/path/to/click-ui.config.js'); + * console.log('Theme:', config.theme); + * ``` + */ +export async function loadConfig(configFile: string): Promise { + try { + // For ES modules (.js, .mjs, .ts) + if (configFile.endsWith('.js') || configFile.endsWith('.mjs') || configFile.endsWith('.ts')) { + // TypeScript files require tsx or ts-node + if (configFile.endsWith('.ts')) { + console.log('āš ļø TypeScript config files require tsx or ts-node to be installed'); + console.log(' Run: npx tsx node_modules/@clickhouse/click-ui/bin/commands/generate.js'); + console.log(' Or convert your config to .js format'); + process.exit(1); + } + + // Convert to file URL for proper ESM import + const fileUrl = pathToFileURL(configFile).href; + const module = await import(fileUrl); + return module.default || module; + } + + // For CommonJS (.cjs) + if (configFile.endsWith('.cjs')) { + return require(configFile); + } + + throw new Error(`Unsupported config file extension: ${configFile}`); + } catch (error: any) { + throw new Error(`Failed to load config from ${configFile}: ${error.message}`); + } +} + +/** + * Validate that config has required theme properties + * + * @param config - Configuration object to validate + * @returns true if config has theme or dark properties + * + * @example + * ```typescript + * if (!validateConfig(config)) { + * console.error('Config missing theme configuration'); + * } + * ``` + */ +export function validateConfig(config: any): boolean { + return !!(config && (config.theme || config.dark)); +} diff --git a/bin/utils/css-generator.js b/bin/utils/css-generator.js new file mode 100644 index 000000000..dc00ecac1 --- /dev/null +++ b/bin/utils/css-generator.js @@ -0,0 +1,32 @@ +#!/usr/bin/env node + +/** + * CSS Generation for CLI + * Used by CLI generate and build-default-theme commands + */ + +import { generateLightDarkVariables, generateThemeOverrides } from '../../src/theme/utils/css-generator.js'; +import { buildCSSOutput } from '../../src/theme/utils/css-builder.js'; + +/** + * Generate CSS from theme config + * Used by CLI generate command + */ +export function generateThemeCSS(config) { + if (!config || (!config.theme && !config.dark)) { + return ''; + } + + // Get light and dark themes + const lightTheme = config.theme || {}; + const darkTheme = { ...config.theme, ...config.dark }; + + // Generate variables using light-dark() for colors + const lightDarkVars = generateLightDarkVariables(lightTheme, darkTheme); + const themeOverrides = generateThemeOverrides(lightTheme, darkTheme); + + // Build CSS using shared builder + return buildCSSOutput(lightDarkVars, themeOverrides, { + headerComment: '/* Click UI Custom Theme */\n/* Generated by click-ui generate command */\n/* Uses light-dark() CSS function for automatic theme switching */\n' + }); +} diff --git a/package.json b/package.json index ef41e6dd5..9df2aff39 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,22 @@ { "name": "@clickhouse/click-ui", - "version": "0.0.244", + "version": "0.0.234-sc-deprecation.10", "description": "Official ClickHouse design system react library", "type": "module", "license": "Apache-2.0", + "sideEffects": [ + "**/*.css", + "**/*.scss", + "./dist/cui.css", + "./dist/cui-components.css", + "./dist/cui-default-theme.css", + "./src/theme/global.scss" + ], "files": [ - "dist" + "dist", + "bin" ], + "bin": "./bin/click-ui.js", "exports": { ".": { "types": "./dist/index.d.ts", @@ -17,7 +27,14 @@ "types": "./dist/index.d.ts", "import": "./dist/click-ui.bundled.es.js", "require": "./dist/click-ui.bundled.umd.js" - } + }, + "./theme": { + "types": "./dist/theme/index.d.ts" + }, + "./cui.css": "./dist/cui.css", + "./cui-components.css": "./dist/cui-components.css", + "./cui-default-theme.css": "./dist/cui-default-theme.css", + "./style.css": "./dist/cui-components.css" }, "main": "./dist/click-ui.umd.js", "module": "./dist/click-ui.es.js", @@ -40,7 +57,10 @@ "chromatic": "npx chromatic", "dev": "vite", "generate-tokens": "node build-tokens.js && prettier --write \"src/theme/tokens/*.ts\" --config .prettierrc", - "lint": "eslint src --report-unused-disable-directives --max-warnings 0", + "lint": "eslint src --report-unused-disable-directives --max-warnings 0 && yarn lint:scss", + "lint:scss": "stylelint \"src/**/*.scss\" --custom-syntax postcss-scss", + "lint:scss:fix": "stylelint \"src/**/*.scss\" --custom-syntax postcss-scss --fix", + "lint:tokens": "node scripts/validate-scss-tokens.js", "prettify": "prettier --write \"src/**/*.{js,jsx,ts,tsx}\" --config .prettierrc", "prettier:check": "prettier --check \"src/**/*.{js,jsx,ts,tsx}\" --config .prettierrc", "preview": "vite preview", @@ -63,11 +83,11 @@ "@radix-ui/react-popover": "1.0.7", "@radix-ui/react-popper": "1.1.3", "@radix-ui/react-radio-group": "1.1.3", - "@radix-ui/react-separator": "1.0.3", "@radix-ui/react-switch": "1.0.2", "@radix-ui/react-tabs": "1.0.4", "@radix-ui/react-toast": "1.1.5", "@radix-ui/react-tooltip": "1.0.7", + "clsx": "^2.1.1", "lodash": "^4.17.21", "react-sortablejs": "^6.1.4", "react-syntax-highlighter": "^15.5.0", @@ -92,11 +112,9 @@ "@types/react-syntax-highlighter": "^15.5.13", "@types/react-window": "^1.8.8", "@types/sortablejs": "^1.15.2", - "@types/styled-components": "^5.1.34", "@typescript-eslint/eslint-plugin": "^8", "@typescript-eslint/parser": "^8", "@vitejs/plugin-react": "^5.1.2", - "babel-plugin-styled-components": "^2.1.4", "chromatic": "^13.3.4", "date-fns": "4.1.0", "dayjs": "1.11.13", @@ -107,15 +125,21 @@ "eslint-plugin-storybook": "^10.0.7", "globals": "^16.5.0", "jsdom": "^24.0.0", + "postcss": "^8.5.6", + "postcss-scss": "^4.0.9", "prettier": "3.7.4", "prop-types": "^15.8.1", "react": "18.3.1", "react-dom": "18.3.1", + "recharts": "^3.4.1", + "rollup": "^4.52.4", + "sass-embedded": "^1.93.0", "storybook": "^10.0.7", "storybook-addon-pseudo-states": "^10.0.7", "style-dictionary": "^5.0.0", - "styled-components": "^6.1.11", - "stylis": "^4.3.0", + "stylelint": "^16.26.1", + "stylelint-config-standard-scss": "^16.0.0", + "stylelint-scss": "^6.14.0", "ts-node": "^10.9.1", "typescript": "^5.5.3", "typescript-eslint": "^8", @@ -127,8 +151,7 @@ "peerDependencies": { "dayjs": "^1.11.13", "react": "^18.2.0", - "react-dom": "^18.2.0", - "styled-components": ">= 5" + "react-dom": "^18.2.0" }, "resolutions": { "@types/react": "18.3.2", diff --git a/scripts/validate-scss-tokens.js b/scripts/validate-scss-tokens.js new file mode 100755 index 000000000..966444dd2 --- /dev/null +++ b/scripts/validate-scss-tokens.js @@ -0,0 +1,165 @@ +#!/usr/bin/env node + +/** + * Validates SCSS token usage in .module.scss files + * Checks for: + * - Typos in token names (tokens.$clickButton... etc) + * - Invalid CSS property values + * - Incorrect token usage patterns + */ + +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { glob } from 'glob'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const rootDir = path.resolve(__dirname, '..'); + +// Token prefixes that should exist +const VALID_TOKEN_PREFIXES = [ + 'tokens.$click', + 'tokens.$global', + 'tokens.$typography', + 'tokens.$transition', +]; + +// Common token patterns (not exhaustive, but catches most typos) +const KNOWN_TOKEN_PATTERNS = [ + // Button tokens + /tokens\.\$clickButtonBasic/, + /tokens\.\$clickButtonIconButton/, + /tokens\.\$clickButtonStroke/, + + // Field tokens + /tokens\.\$clickFieldColor/, + /tokens\.\$clickFieldSpace/, + /tokens\.\$clickFieldRadii/, + /tokens\.\$clickFieldSize/, + + // Checkbox tokens + /tokens\.\$clickCheckbox/, + /tokens\.\$clickRadio/, + /tokens\.\$clickSwitch/, + + // Global tokens + /tokens\.\$clickGlobalColor/, + /tokens\.\$globalColor/, + + // Typography tokens + /tokens\.\$typography/, + + // Transition tokens + /tokens\.\$transition/, +]; + +// Invalid patterns that indicate typos or mistakes +const INVALID_PATTERNS = [ + { + pattern: /token\.\$/, + message: 'Missing "s" - should be "tokens.$" not "token.$"', + }, + { + pattern: /tokens\.\$[A-Z]/, + message: 'Token should start with lowercase after $ (e.g., tokens.$clickButton, not tokens.$ClickButton)', + }, +]; + +async function findScssFiles() { + const pattern = path.join(rootDir, 'src/**/*.module.scss'); + return await glob(pattern, { ignore: ['**/node_modules/**', '**/dist/**'] }); +} + +function extractTokenUsage(content) { + const tokenRegex = /tokens\.\$[a-zA-Z0-9_]+/g; + const matches = content.match(tokenRegex) || []; + return [...new Set(matches)]; // Remove duplicates +} + +function validateToken(token, lineNumber, filePath) { + const errors = []; + + // Check for invalid patterns + for (const { pattern, message } of INVALID_PATTERNS) { + if (pattern.test(token)) { + errors.push({ + file: filePath, + line: lineNumber, + token, + message, + }); + } + } + + // Check if token starts with a known prefix + const hasValidPrefix = VALID_TOKEN_PREFIXES.some(prefix => token.startsWith(prefix)); + if (!hasValidPrefix) { + errors.push({ + file: filePath, + line: lineNumber, + token, + message: `Token does not start with a known prefix. Valid prefixes: ${VALID_TOKEN_PREFIXES.join(', ')}`, + }); + } + + return errors; +} + +function validateFile(filePath) { + const content = fs.readFileSync(filePath, 'utf-8'); + const lines = content.split('\n'); + const errors = []; + + lines.forEach((line, index) => { + const tokens = extractTokenUsage(line); + tokens.forEach(token => { + const tokenErrors = validateToken(token, index + 1, filePath); + errors.push(...tokenErrors); + }); + }); + + return errors; +} + +async function main() { + console.log('šŸ” Validating SCSS token usage...\n'); + + const scssFiles = await findScssFiles(); + console.log(`Found ${scssFiles.length} SCSS module files\n`); + + let totalErrors = 0; + const errorsByFile = {}; + + for (const file of scssFiles) { + const errors = validateFile(file); + if (errors.length > 0) { + const relativePath = path.relative(rootDir, file); + errorsByFile[relativePath] = errors; + totalErrors += errors.length; + } + } + + if (totalErrors === 0) { + console.log('āœ… No token validation errors found!'); + process.exit(0); + } + + console.log(`āŒ Found ${totalErrors} token validation errors:\n`); + + Object.entries(errorsByFile).forEach(([file, errors]) => { + console.log(`\nšŸ“„ ${file}`); + errors.forEach(error => { + console.log(` Line ${error.line}: ${error.token}`); + console.log(` └─ ${error.message}`); + }); + }); + + console.log(`\nāŒ Total errors: ${totalErrors}`); + process.exit(1); +} + +main().catch(error => { + console.error('Error running validation:', error); + process.exit(1); +}); diff --git a/setupTests.ts b/setupTests.ts index 7a79577a8..64201b3e8 100644 --- a/setupTests.ts +++ b/setupTests.ts @@ -1,3 +1,18 @@ import { TextEncoder } from "util"; global.TextEncoder = TextEncoder; + +// Mock window.matchMedia for tests +Object.defineProperty(window, "matchMedia", { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, // deprecated + removeListener: () => {}, // deprecated + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => true, + }), +}); diff --git a/src/App.css b/src/App.css deleted file mode 100644 index 8b1378917..000000000 --- a/src/App.css +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/App.module.css b/src/App.module.scss similarity index 51% rename from src/App.module.css rename to src/App.module.scss index 3904fef61..6656b0524 100644 --- a/src/App.module.css +++ b/src/App.module.scss @@ -1,3 +1,10 @@ +@use "@/styles/tokens-light-dark" as tokens; + +.cuiBackgroundWrapper { + background: var(--global-color-background-default); + padding: 6rem; +} + .main { display: flex; flex-direction: column; @@ -15,22 +22,22 @@ width: 100%; z-index: 2; font-family: var(--font-mono); -} -.description a { - display: flex; - justify-content: center; - align-items: center; - gap: 0.5rem; -} + a { + display: flex; + justify-content: center; + align-items: center; + gap: 0.5rem; + } -.description p { - position: relative; - margin: 0; - padding: 1rem; - background-color: rgba(var(--callout-rgb), 0.5); - border: 1px solid rgba(var(--callout-border-rgb), 0.3); - border-radius: var(--border-radius); + p { + position: relative; + margin: 0; + padding: 1rem; + background-color: rgba(var(--callout-rgb), 0.5); + border: 1px solid rgba(var(--callout-border-rgb), 0.3); + border-radius: var(--border-radius); + } } .code { @@ -51,24 +58,24 @@ background: rgba(var(--card-rgb), 0); border: 1px solid rgba(var(--card-border-rgb), 0); transition: background 200ms, border 200ms; -} -.card span { - display: inline-block; - transition: transform 200ms; -} + span { + display: inline-block; + transition: transform 200ms; + } -.card h2 { - font-weight: 600; - margin-bottom: 0.7rem; -} + h2 { + font-weight: 600; + margin-bottom: 0.7rem; + } -.card p { - margin: 0; - opacity: 0.6; - font-size: 0.9rem; - line-height: 1.5; - max-width: 30ch; + p { + margin: 0; + opacity: 0.6; + font-size: 0.9rem; + line-height: 1.5; + max-width: 30ch; + } } .center { @@ -77,44 +84,52 @@ align-items: center; position: relative; padding: 4rem 0; -} -.center::before { - background: var(--secondary-glow); - border-radius: 50%; - width: 480px; - height: 360px; - margin-left: -400px; -} + &::before { + background: var(--secondary-glow); + border-radius: 50%; + width: 480px; + height: 360px; + margin-left: -400px; + } -.center::after { - background: var(--primary-glow); - width: 240px; - height: 180px; - z-index: -1; -} + &::after { + background: var(--primary-glow); + width: 240px; + height: 180px; + z-index: -1; + } -.center::before, -.center::after { - content: ''; - left: 50%; - position: absolute; - filter: blur(45px); - transform: translateZ(0); + &::before, + &::after { + content: ''; + left: 50%; + position: absolute; + filter: blur(45px); + transform: translateZ(0); + } } .logo { position: relative; } + +.flexWrap { + display: flex; + flex-flow: row wrap; + gap: 10px; + padding: 10px 0; +} + /* Enable hover only on non-touch devices */ @media (hover: hover) and (pointer: fine) { .card:hover { background: rgba(var(--card-rgb), 0.1); border: 1px solid rgba(var(--card-border-rgb), 0.15); - } - .card:hover span { - transform: translateX(4px); + span { + transform: translateX(4px); + } } } @@ -139,65 +154,65 @@ .card { padding: 1rem 2.5rem; - } - .card h2 { - margin-bottom: 0.5rem; + h2 { + margin-bottom: 0.5rem; + } } .center { padding: 8rem 0 6rem; - } - .center::before { - transform: none; - height: 300px; + &::before { + transform: none; + height: 300px; + } } .description { font-size: 0.8rem; - } - .description a { - padding: 1rem; - } - - .description p, - .description div { - display: flex; - justify-content: center; - position: fixed; - width: 100%; - } - - .description p { - align-items: center; - inset: 0 0 auto; - padding: 2rem 1rem 1.4rem; - border-radius: 0; - border: none; - border-bottom: 1px solid rgba(var(--callout-border-rgb), 0.25); - background: linear-gradient( - to bottom, - rgba(var(--background-start-rgb), 1), - rgba(var(--callout-rgb), 0.5) - ); - background-clip: padding-box; - backdrop-filter: blur(24px); - } - - .description div { - align-items: flex-end; - pointer-events: none; - inset: auto 0 0; - padding: 2rem; - height: 200px; - background: linear-gradient( - to bottom, - transparent 0%, - rgb(var(--background-end-rgb)) 40% - ); - z-index: 1; + a { + padding: 1rem; + } + + p, + div { + display: flex; + justify-content: center; + position: fixed; + width: 100%; + } + + p { + align-items: center; + inset: 0 0 auto; + padding: 2rem 1rem 1.4rem; + border-radius: 0; + border: none; + border-bottom: 1px solid rgba(var(--callout-border-rgb), 0.25); + background: linear-gradient( + to bottom, + rgba(var(--background-start-rgb), 1), + rgba(var(--callout-rgb), 0.5) + ); + background-clip: padding-box; + backdrop-filter: blur(24px); + } + + div { + align-items: flex-end; + pointer-events: none; + inset: auto 0 0; + padding: 2rem; + height: 200px; + background: linear-gradient( + to bottom, + transparent 0%, + rgb(var(--background-end-rgb)) 40% + ); + z-index: 1; + } } } @@ -225,11 +240,4 @@ to { transform: rotate(0deg); } -} - -.flexWrap { - display: flex; - flex-flow: row wrap; - gap: 10px; - padding: 10px 0; -} +} \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index bd7863f41..a78736a47 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,9 +1,8 @@ import { useRef, useState } from "react"; -import "@/styles/globals.css"; - -import styles from "./App.module.css"; -import { ThemeName } from "./theme"; +import styles from "./App.module.scss"; +import { ThemeName } from "@/theme"; +import { ClickUIProvider } from "@/theme/ClickUIProvider"; import { Accordion, Alert, @@ -11,7 +10,6 @@ import { Badge, Button, ButtonGroup, - ClickUIProvider, CardSecondary, Checkbox, DangerAlert, @@ -47,15 +45,9 @@ import { } from "@/components"; import { Dialog } from "@/components/Dialog/Dialog"; import { ConfirmationDialog } from "@/components/ConfirmationDialog/ConfirmationDialog"; -import { ProgressBar } from "./components/ProgressBar/ProgressBar"; -import GridExample from "./examples/GridExample"; -import MultiAccordionDemo from "./components/MultiAccordion/MultiAccordionDemo"; -import { styled } from "styled-components"; - -const BackgroundWrapper = styled.div` - background: ${({ theme }) => theme.global.color.background.default}; - padding: 6rem; -`; +import { ProgressBar } from "@/components/ProgressBar/ProgressBar"; +import GridExample from "@/examples/GridExample"; +import MultiAccordionDemo from "@/components/MultiAccordion/MultiAccordionDemo"; const headers: Array = [ { label: "Company", isSortable: true, sortDir: "asc" }, { label: "Contact", isSortable: true, sortDir: "desc", sortPosition: "start" }, @@ -109,6 +101,34 @@ const App = () => { theme={currentTheme} config={{ tooltip: { delayDuration: 0 } }} > +
+ + + +
- + )} {showIcon && ( - - - + )} - - {title && {title}} - {text} - +
+ {title &&
{title}
} +
{text}
+
{dismissible && ( - @@ -93,87 +107,12 @@ const Alert = ({ name="cross" aria-label="close" /> - + )} - + ) : null; }; -const Wrapper = styled.div<{ - $state: AlertState; - $size: AlertSize; - $type: AlertType; -}>` - display: flex; - border-radius: ${({ $type, theme }) => - $type === "banner" ? theme.sizes[0] : theme.click.alert.radii.end}; - justify-content: ${({ $type }) => ($type === "banner" ? "center" : "start")}; - overflow: hidden; - background-color: ${({ $state = "neutral", theme }) => - theme.click.alert.color.background[$state]}; - color: ${({ $state = "neutral", theme }) => theme.click.alert.color.text[$state]}; - width: 100%; -`; - -const IconWrapper = styled.div<{ - $state: AlertState; - $size: AlertSize; - $type: AlertType; -}>` - display: flex; - align-items: center; - background-color: ${({ $state = "neutral", $type, theme }) => - $type === "banner" ? "none" : theme.click.alert.color.iconBackground[$state]}; - ${({ $state = "neutral", $size, theme }) => ` - color: ${theme.click.alert.color.iconForeground[$state]}; - padding: ${theme.click.alert[$size].space.y} 0 ${theme.click.alert[$size].space.y} ${theme.click.alert[$size].space.x}; - `} -`; - -const StyledIcon = styled(Icon)<{ $size: AlertSize }>` - ${({ $size, theme }) => ` - height: ${theme.click.alert[$size].icon.height}; - width: ${theme.click.alert[$size].icon.width}; - `} -`; -const TextWrapper = styled.div<{ $state: AlertState; $size: AlertSize }>` - display: flex; - flex-flow: column; - word-break: break-word; - ${({ $size, theme }) => ` - gap: ${theme.click.alert[$size].space.gap}; - padding: ${theme.click.alert[$size].space.y} ${theme.click.alert[$size].space.x}; - `} - - a, - a:focus, - a:visited, - a:hover { - font: inherit; - color: inherit; - text-decoration: underline; - } -`; - -const Title = styled.h6<{ $size: AlertSize }>` - margin: 0; - font: ${({ theme, $size }) => theme.click.alert[$size].typography.title.default}; -`; -const Text = styled.div<{ $size: AlertSize }>` - margin: 0; - font: ${({ theme, $size }) => theme.click.alert[$size].typography.text.default}; -`; - -const DismissWrapper = styled.button` - display: flex; - align-items: center; - margin-left: auto; - border: none; - background-color: transparent; - color: inherit; - cursor: pointer; -`; - const DangerAlert = (props: AlertProps) => ( ( +
+
+

States

+
+
+

Default

+ console.log("Selected:", value)}> + Option 1 + Option 2 + Option 3 + +
+
+

Disabled

+ console.log("Selected:", value)} + > + Option 1 + Option 2 + Option 3 + +
+
+
+ +
+

With Label

+
+ console.log("Selected:", value)} + > + Option 1 + Option 2 + Option 3 + +
+
+ +
+

With Error

+
+ console.log("Selected:", value)} + > + Option 1 + Option 2 + Option 3 + +
+
+ +
+

Label Orientation

+
+
+

Horizontal

+ console.log("Selected:", value)} + > + Option 1 + Option 2 + Option 3 + +
+
+

Vertical

+ console.log("Selected:", value)} + > + Option 1 + Option 2 + Option 3 + +
+
+
+ +
+

Label Position

+
+
+

Start

+ console.log("Selected:", value)} + > + Option 1 + Option 2 + Option 3 + +
+
+

End

+ console.log("Selected:", value)} + > + Option 1 + Option 2 + Option 3 + +
+
+
+ +
+

With Groups

+
+ console.log("Selected:", value)}> + + Apple + Banana + Cherry + + + Carrot + Potato + Tomato + + +
+
+ +
+

With Icons

+
+ console.log("Selected:", value)}> + + User Profile + + + Settings + + + Logout + + +
+
+ +
+

With Disabled Items

+
+ console.log("Selected:", value)}> + Option 1 + + Option 2 (Disabled) + + Option 3 + + Option 4 (Disabled) + + +
+
+ +
+

Using Options Prop

+
+ console.log("Selected:", value)} + /> +
+
+ +
+

Custom Placeholder

+
+ console.log("Selected:", value)} + > + Option 1 + Option 2 + Option 3 + +
+
+
+ ), + parameters: { + controls: { disable: true }, + actions: { disable: true }, + pseudo: { + hover: ['[data-testid="autocomplete-trigger"]'], + focus: ['[data-testid="autocomplete-trigger"]'], + }, + chromatic: { + delay: 300, + }, + }, +}; diff --git a/src/components/AutoComplete/AutoComplete.test.tsx b/src/components/AutoComplete/AutoComplete.test.tsx index 60db6cc57..d7e72707f 100644 --- a/src/components/AutoComplete/AutoComplete.test.tsx +++ b/src/components/AutoComplete/AutoComplete.test.tsx @@ -1,7 +1,7 @@ import { act, fireEvent } from "@testing-library/react"; import { AutoComplete, AutoCompleteProps } from "@/components"; import { renderCUI } from "@/utils/test-utils"; -import { selectOptions } from "../Select/selectOptions"; +import { selectOptions } from "@/components/Select/selectOptions"; describe("AutoComplete", () => { beforeAll(() => { window.HTMLElement.prototype.scrollIntoView = vi.fn(); diff --git a/src/components/AutoComplete/AutoComplete.tsx b/src/components/AutoComplete/AutoComplete.tsx index 04553ecc6..e57e1e35c 100644 --- a/src/components/AutoComplete/AutoComplete.tsx +++ b/src/components/AutoComplete/AutoComplete.tsx @@ -1,7 +1,9 @@ +"use client"; + import { Children, + ComponentPropsWithoutRef, FunctionComponent, - HTMLAttributes, KeyboardEventHandler, MouseEventHandler, ReactNode, @@ -22,16 +24,17 @@ import { SearchField, Separator, } from "@/components"; -import { styled } from "styled-components"; -import { GenericMenuItem } from "../GenericMenu"; +import clsx from "clsx"; +import { GenericMenuItem } from "@/components/GenericMenu"; import { useOption, useSearch } from "./useOption"; -import IconWrapper from "../IconWrapper/IconWrapper"; +import { IconWrapper } from "@/components"; import { OptionContext } from "./OptionContext"; import { mergeRefs } from "@/utils/mergeRefs"; import { getTextFromNodes } from "@/lib/getTextFromNodes"; import AutoCompleteOptionList from "./AutoCompleteOptionList"; +import styles from "./AutoComplete.module.scss"; -type DivProps = HTMLAttributes; +type DivProps = ComponentPropsWithoutRef<"div">; interface SelectItemComponentProps extends Omit< DivProps, "disabled" | "onSelect" | "value" | "children" @@ -54,7 +57,7 @@ type SelectItemLabel = { label: ReactNode; }; export interface SelectGroupProps extends Omit< - HTMLAttributes, + ComponentPropsWithoutRef<"div">, "heading" > { heading: ReactNode; @@ -111,78 +114,6 @@ type SelectItemObject = { export type AutoCompleteProps = (SelectOptionType & Props) | (SelectChildrenType & Props); -export const SelectPopoverRoot = styled(Root)` - width: 100%; - ${({ theme }) => ` - border: 1px solid ${theme.click.genericMenu.item.color.default.stroke.default}; - background: ${theme.click.genericMenu.item.color.default.background.default}; - box-shadow: 0px 1px 3px 0px rgba(16, 24, 40, 0.1), - 0px 1px 2px 0px rgba(16, 24, 40, 0.06); - border-radius: 0.25rem; - `} - overflow: hidden; - display: flex; - padding: 0.5rem 0rem; - align-items: flex-start; - gap: 0.625rem; -`; - -const PopoverContent = styled(Content)` - width: var(--radix-popover-trigger-width); - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 5px; -`; -const SelectGroupContainer = styled.div` - display: flex; - flex-direction: column; - align-items: flex-start; - justify-content: center; - width: -webkit-fill-available; - width: fill-available; - width: stretch; - overflow: hidden; - background: transparent; - &[aria-selected] { - outline: none; - } - - ${({ theme }) => ` - font: ${theme.click.genericMenu.item.typography.sectionHeader.default}; - color: ${theme.click.genericMenu.item.color.default.text.muted}; - `}; - &[hidden] { - display: none; - } -`; - -const SelectGroupName = styled.div` - display: flex; - width: 100%; - flex-direction: column; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - ${({ theme }) => ` - font: ${theme.click.genericMenu.item.typography.sectionHeader.default}; - color: ${theme.click.genericMenu.item.color.default.text.muted}; - padding: ${theme.click.genericMenu.sectionHeader.space.top} ${theme.click.genericMenu.item.space.x} ${theme.click.genericMenu.sectionHeader.space.bottom}; - gap: ${theme.click.genericMenu.item.space.gap}; - border-bottom: 1px solid ${theme.click.genericMenu.item.color.default.stroke.default}; - `} -`; - -const SelectGroupContent = styled.div` - width: inherit; -`; - -const SelectListContent = styled.div` - width: inherit; - overflow: overlay; - flex: 1; -`; - type CallbackProps = SelectItemObject & { nodeProps: SelectItemProps; }; @@ -226,37 +157,6 @@ const childrenToComboboxItemArray = ( return []; }); }; -const SelectNoDataContainer = styled.div` - border: none; - display: block; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - text-align: left; - cursor: default; - &[hidden="true"] { - display: none; - } - ${({ theme }) => ` - font: ${theme.click.genericMenu.button.typography.label.default} - padding: ${theme.click.genericMenu.button.space.y} ${theme.click.genericMenu.item.space.x}; - background: ${theme.click.genericMenu.button.color.background.default}; - color: ${theme.click.genericMenu.button.color.label.default}; - `} -`; - -const SelectList = styled.div` - display: flex; - flex-direction: column; - width: inherit; - max-height: var(--radix-popover-content-available-height); - ${({ theme }) => ` - border: 1px solid ${theme.click.genericMenu.item.color.default.stroke.default}; - background: ${theme.click.genericMenu.item.color.default.background.default}; - box-shadow: ${theme.click.genericMenu.panel.shadow.default}; - border-radius: 0.25rem; - `} -`; export const AutoComplete = ({ onSelect: onSelectProp, @@ -486,7 +386,7 @@ export const AutoComplete = ({ }; return ( - @@ -508,7 +408,8 @@ export const AutoComplete = ({ - { @@ -526,8 +427,8 @@ export const AutoComplete = ({ }} onFocusOutside={onFocusOutside} > - - +
+
{options && options.length > 0 ? ( - +
{visibleList.current.length === 0 && ( - +
No Options found{search.length > 0 ? ` for "${search}" ` : ""} - +
)} - - +
+
-
+ ); }; @@ -555,7 +459,8 @@ export const Group = forwardRef( ({ children, heading, ...props }, forwardedRef) => { useSearch(); return ( - ( }, ])} > - {heading} - {children} - +
{heading}
+
{children}
+ ); } ); Group.displayName = "AutoComplete.Group"; -const CheckIcon = styled.svg<{ $showCheck: boolean }>` - opacity: ${({ $showCheck }) => ($showCheck ? 1 : 0)}; -`; - export const Item = forwardRef( ( { @@ -642,11 +543,10 @@ export const Item = forwardRef( > {label ?? children} - {separator && } diff --git a/src/components/Avatar/Avatar.module.scss b/src/components/Avatar/Avatar.module.scss new file mode 100644 index 000000000..5f957191a --- /dev/null +++ b/src/components/Avatar/Avatar.module.scss @@ -0,0 +1,65 @@ +@use "cui-mixins" as mixins; +@use "cui-variants" as variants; + +.cuiAvatarRoot { + width: tokens.$clickAvatarSizeWidth; + height: tokens.$clickAvatarSizeHeight; + display: inline-flex; + align-items: center; + justify-content: center; + vertical-align: middle; + overflow: hidden; + user-select: none; + background-color: tokens.$clickAvatarColorBackgroundDefault; + color: tokens.$clickAvatarColorTextDefault; + border-radius: tokens.$clickAvatarRadiiAll; + + &:active { + background-color: tokens.$clickAvatarColorBackgroundActive; + color: tokens.$clickAvatarColorTextActive; + } + + &:hover { + background-color: tokens.$clickAvatarColorBackgroundHover; + color: tokens.$clickAvatarColorTextHover; + } +} + +.cuiAvatarImage { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: inherit; +} + +.cuiAvatarFallback { + width: tokens.$clickAvatarSizeLabelWidth; + display: inline-flex; + align-items: center; + justify-content: center; + + // Text size variants - using :where() for low specificity + @include variants.variant('cuiTextSizeMd') { + font: tokens.$clickAvatarTypographyLabelMdDefault; + + .cuiAvatarRoot:active & { + font: tokens.$clickAvatarTypographyLabelMdActive; + } + + .cuiAvatarRoot:hover & { + font: tokens.$clickAvatarTypographyLabelMdHover; + } + } + + @include variants.variant('cuiTextSizeSm') { + font: tokens.$clickAvatarTypographyLabelSmDefault; + + .cuiAvatarRoot:active & { + font: tokens.$clickAvatarTypographyLabelSmActive; + } + + .cuiAvatarRoot:hover & { + font: tokens.$clickAvatarTypographyLabelSmHover; + } + } +} diff --git a/src/components/Avatar/Avatar.stories.tsx b/src/components/Avatar/Avatar.stories.tsx index dec3f09a6..ec8eec48d 100644 --- a/src/components/Avatar/Avatar.stories.tsx +++ b/src/components/Avatar/Avatar.stories.tsx @@ -16,3 +16,170 @@ export const Playground: Story = { text: "CM", }, }; + +export const Variations: Story = { + parameters: { + controls: { disable: true }, + actions: { disable: true }, + pseudo: { + hover: ".cuiAvatarRoot", + focus: ".cuiAvatarRoot", + active: ".cuiAvatarRoot", + }, + }, + render: () => ( +
+
+

Text Sizes

+
+ + +
+
+ +
+

Different Initials

+
+ + + + + + +
+
+ +
+

With Images

+
+ + + +
+
+ +
+

Image Fallback

+
+ + +
+
+ +
+

Size Variations with Text Size

+
+
+ Small Text + +
+
+ Medium Text + +
+
+
+ +
+

Various Name Formats

+
+ + + + + + + +
+
+ +
+

Custom Styling

+
+ + + +
+
+ +
+

Image with Different Sizes

+
+ + + +
+
+
+ ), +}; diff --git a/src/components/Avatar/Avatar.tsx b/src/components/Avatar/Avatar.tsx index 636d066f4..d03d031e2 100644 --- a/src/components/Avatar/Avatar.tsx +++ b/src/components/Avatar/Avatar.tsx @@ -4,7 +4,9 @@ import { Image, Root, } from "@radix-ui/react-avatar"; -import { styled } from "styled-components"; +import clsx from "clsx"; +import { capitalize } from "../../utils/capitalize"; +import styles from "./Avatar.module.scss"; type TextSize = "md" | "sm"; @@ -19,74 +21,40 @@ export interface AvatarProps extends RadixAvatarProps { srcSet?: string; } -const Avatar = ({ text, textSize = "sm", src, srcSet, ...delegated }: AvatarProps) => ( - - - { + const textSizeClass = `cuiTextSize${capitalize(textSize)}`; + + return ( + - {text - .trim() - .replace(/(^.)([^ ]* )?(.).*/, "$1$3") - .trim() - .toUpperCase()} - - -); - -const StyledRoot = styled(Root)` - width: ${props => props.theme.click.avatar.size.width}; - height: ${props => props.theme.click.avatar.size.height}; - display: inline-flex; - align-items: center; - justify-content: center; - vertical-align: middle; - overflow: hidden; - user-select: none; - - background-color: ${props => props.theme.click.avatar.color.background.default}; - color: ${props => props.theme.click.avatar.color.text.default}; - border-radius: ${props => props.theme.click.avatar.radii.all}; - - &:active { - background-color: ${props => props.theme.click.avatar.color.background.active}; - color: ${props => props.theme.click.avatar.color.text.active}; - } - - &:hover { - background-color: ${props => props.theme.click.avatar.color.background.hover}; - color: ${props => props.theme.click.avatar.color.text.hover}; - } -`; - -const AvatarImage = styled(Image)` - width: 100%; - height: 100%; - object-fit: cover; - border-radius: inherit; -`; - -const StyledFallback = styled(Fallback)<{ $textSize: TextSize }>` - width: ${props => props.theme.click.avatar.size.label.width}; - display: inline-flex; - align-items: center; - justify-content: center; - ${({ theme, $textSize = "sm" }) => ` - font: ${theme.click.avatar.typography.label[$textSize].default}; - - &:active { - font: ${theme.click.avatar.typography.label[$textSize].active}; - } - - &:hover { - font: ${theme.click.avatar.typography.label[$textSize].hover}; - } - `} -`; + {text} + + {text + .trim() + .replace(/(^.)([^ ]* )?(.).*/, "$1$3") + .trim() + .toUpperCase()} + + + ); +}; export { Avatar }; diff --git a/src/components/Badge/Badge.module.scss b/src/components/Badge/Badge.module.scss new file mode 100644 index 000000000..54efa5586 --- /dev/null +++ b/src/components/Badge/Badge.module.scss @@ -0,0 +1,382 @@ +@use "cui-mixins" as mixins; +@use "cui-variants" as variants; + +// Base wrapper styles - using variant mixins for modern CSS +.cuiWrapper { + display: inline-flex; + border-radius: tokens.$clickBadgeRadiiAll; + + // Size variants + @include variants.variant('cuiSizeSm') { + padding: tokens.$clickBadgeSpaceSmY tokens.$clickBadgeSpaceSmX; + font: tokens.$clickBadgeTypographyLabelSmDefault; + } + + @include variants.variant('cuiSizeMd') { + padding: tokens.$clickBadgeSpaceMdY tokens.$clickBadgeSpaceMdX; + font: tokens.$clickBadgeTypographyLabelMdDefault; + } + + // Type and state combinations - opaque + @include variants.variant('cuiTypeOpaque') { + @include variants.variant('cuiStateDefault') { + background-color: tokens.$clickBadgeOpaqueColorBackgroundDefault; + color: tokens.$clickBadgeOpaqueColorTextDefault; + border: tokens.$clickBadgeStroke solid tokens.$clickBadgeOpaqueColorStrokeDefault; + } + + @include variants.variant('cuiStateSuccess') { + background-color: tokens.$clickBadgeOpaqueColorBackgroundSuccess; + color: tokens.$clickBadgeOpaqueColorTextSuccess; + border: tokens.$clickBadgeStroke solid tokens.$clickBadgeOpaqueColorStrokeSuccess; + } + + @include variants.variant('cuiStateNeutral') { + background-color: tokens.$clickBadgeOpaqueColorBackgroundNeutral; + color: tokens.$clickBadgeOpaqueColorTextNeutral; + border: tokens.$clickBadgeStroke solid tokens.$clickBadgeOpaqueColorStrokeNeutral; + } + + @include variants.variant('cuiStateDanger') { + background-color: tokens.$clickBadgeOpaqueColorBackgroundDanger; + color: tokens.$clickBadgeOpaqueColorTextDanger; + border: tokens.$clickBadgeStroke solid tokens.$clickBadgeOpaqueColorStrokeDanger; + } + + @include variants.variant('cuiStateDisabled') { + background-color: tokens.$clickBadgeOpaqueColorBackgroundDisabled; + color: tokens.$clickBadgeOpaqueColorTextDisabled; + border: tokens.$clickBadgeStroke solid tokens.$clickBadgeOpaqueColorStrokeDisabled; + } + + @include variants.variant('cuiStateWarning') { + background-color: tokens.$clickBadgeOpaqueColorBackgroundWarning; + color: tokens.$clickBadgeOpaqueColorTextWarning; + border: tokens.$clickBadgeStroke solid tokens.$clickBadgeOpaqueColorStrokeWarning; + } + + @include variants.variant('cuiStateInfo') { + background-color: tokens.$clickBadgeOpaqueColorBackgroundInfo; + color: tokens.$clickBadgeOpaqueColorTextInfo; + border: tokens.$clickBadgeStroke solid tokens.$clickBadgeOpaqueColorStrokeInfo; + } + } + + // Type and state combinations - solid + @include variants.variant('cuiTypeSolid') { + @include variants.variant('cuiStateDefault') { + background-color: tokens.$clickBadgeSolidColorBackgroundDefault; + color: tokens.$clickBadgeSolidColorTextDefault; + border: tokens.$clickBadgeStroke solid tokens.$clickBadgeSolidColorStrokeDefault; + } + + @include variants.variant('cuiStateSuccess') { + background-color: tokens.$clickBadgeSolidColorBackgroundSuccess; + color: tokens.$clickBadgeSolidColorTextSuccess; + border: tokens.$clickBadgeStroke solid tokens.$clickBadgeSolidColorStrokeSuccess; + } + + @include variants.variant('cuiStateNeutral') { + background-color: tokens.$clickBadgeSolidColorBackgroundNeutral; + color: tokens.$clickBadgeSolidColorTextNeutral; + border: tokens.$clickBadgeStroke solid tokens.$clickBadgeSolidColorStrokeNeutral; + } + + @include variants.variant('cuiStateDanger') { + background-color: tokens.$clickBadgeSolidColorBackgroundDanger; + color: tokens.$clickBadgeSolidColorTextDanger; + border: tokens.$clickBadgeStroke solid tokens.$clickBadgeSolidColorStrokeDanger; + } + + @include variants.variant('cuiStateDisabled') { + background-color: tokens.$clickBadgeSolidColorBackgroundDisabled; + color: tokens.$clickBadgeSolidColorTextDisabled; + border: tokens.$clickBadgeStroke solid tokens.$clickBadgeSolidColorStrokeDisabled; + } + + @include variants.variant('cuiStateWarning') { + background-color: tokens.$clickBadgeSolidColorBackgroundWarning; + color: tokens.$clickBadgeSolidColorTextWarning; + border: tokens.$clickBadgeStroke solid tokens.$clickBadgeSolidColorStrokeWarning; + } + + @include variants.variant('cuiStateInfo') { + background-color: tokens.$clickBadgeSolidColorBackgroundInfo; + color: tokens.$clickBadgeSolidColorTextInfo; + border: tokens.$clickBadgeStroke solid tokens.$clickBadgeSolidColorStrokeInfo; + } + } +} + +// Content container - inherits sizing from wrapper +.cuiContent { + display: inline-flex; + align-items: center; + max-width: 100%; + justify-content: flex-start; +} + +// Size-specific gap using parent class selectors +.cuiWrapper { + @include variants.variant('cuiSizeSm') { + .cuiContent { + gap: tokens.$clickBadgeSpaceSmGap; + } + } + + @include variants.variant('cuiSizeMd') { + .cuiContent { + gap: tokens.$clickBadgeSpaceMdGap; + } + } +} + +// Badge content wrapper +.cuiBadgeContent { + width: auto; + overflow: hidden; + + svg { + gap: inherit; + } +} + +// Icon size and color variants using parent wrapper classes +.cuiWrapper { + // Icon size variants + @include variants.variant('cuiSizeSm') { + .cuiBadgeContent svg { + height: tokens.$clickBadgeIconSmSizeHeight; + width: tokens.$clickBadgeIconSmSizeWidth; + } + } + + @include variants.variant('cuiSizeMd') { + .cuiBadgeContent svg { + height: tokens.$clickBadgeIconMdSizeHeight; + width: tokens.$clickBadgeIconMdSizeWidth; + } + } + + // Icon color variants for opaque type + @include variants.variant('cuiTypeOpaque') { + @include variants.variant('cuiStateDefault') { + .cuiBadgeContent svg { + color: tokens.$clickBadgeOpaqueColorTextDefault; + } + } + + @include variants.variant('cuiStateSuccess') { + .cuiBadgeContent svg { + color: tokens.$clickBadgeOpaqueColorTextSuccess; + } + } + + @include variants.variant('cuiStateNeutral') { + .cuiBadgeContent svg { + color: tokens.$clickBadgeOpaqueColorTextNeutral; + } + } + + @include variants.variant('cuiStateDanger') { + .cuiBadgeContent svg { + color: tokens.$clickBadgeOpaqueColorTextDanger; + } + } + + @include variants.variant('cuiStateDisabled') { + .cuiBadgeContent svg { + color: tokens.$clickBadgeOpaqueColorTextDisabled; + } + } + + @include variants.variant('cuiStateWarning') { + .cuiBadgeContent svg { + color: tokens.$clickBadgeOpaqueColorTextWarning; + } + } + + @include variants.variant('cuiStateInfo') { + .cuiBadgeContent svg { + color: tokens.$clickBadgeOpaqueColorTextInfo; + } + } + } + + // Icon color variants for solid type + @include variants.variant('cuiTypeSolid') { + @include variants.variant('cuiStateDefault') { + .cuiBadgeContent svg { + color: tokens.$clickBadgeSolidColorTextDefault; + } + } + + @include variants.variant('cuiStateSuccess') { + .cuiBadgeContent svg { + color: tokens.$clickBadgeSolidColorTextSuccess; + } + } + + @include variants.variant('cuiStateNeutral') { + .cuiBadgeContent svg { + color: tokens.$clickBadgeSolidColorTextNeutral; + } + } + + @include variants.variant('cuiStateDanger') { + .cuiBadgeContent svg { + color: tokens.$clickBadgeSolidColorTextDanger; + } + } + + @include variants.variant('cuiStateDisabled') { + .cuiBadgeContent svg { + color: tokens.$clickBadgeSolidColorTextDisabled; + } + } + + @include variants.variant('cuiStateWarning') { + .cuiBadgeContent svg { + color: tokens.$clickBadgeSolidColorTextWarning; + } + } + + @include variants.variant('cuiStateInfo') { + .cuiBadgeContent svg { + color: tokens.$clickBadgeSolidColorTextInfo; + } + } + } +} + +// Close icon container +.cuiCloseIcon { + // Base styles only +} + +// Close icon size and color variants using parent wrapper classes +.cuiWrapper { + // Size variants + @include variants.variant('cuiSizeSm') { + .cuiCloseIcon { + height: tokens.$clickBadgeIconSmSizeHeight; + width: tokens.$clickBadgeIconSmSizeWidth; + } + } + + @include variants.variant('cuiSizeMd') { + .cuiCloseIcon { + height: tokens.$clickBadgeIconMdSizeHeight; + width: tokens.$clickBadgeIconMdSizeWidth; + } + } + + // Color variants for opaque type + @include variants.variant('cuiTypeOpaque') { + @include variants.variant('cuiStateDefault') { + .cuiCloseIcon { + color: tokens.$clickBadgeOpaqueColorTextDefault; + } + } + + @include variants.variant('cuiStateSuccess') { + .cuiCloseIcon { + color: tokens.$clickBadgeOpaqueColorTextSuccess; + } + } + + @include variants.variant('cuiStateNeutral') { + .cuiCloseIcon { + color: tokens.$clickBadgeOpaqueColorTextNeutral; + } + } + + @include variants.variant('cuiStateDanger') { + .cuiCloseIcon { + color: tokens.$clickBadgeOpaqueColorTextDanger; + } + } + + @include variants.variant('cuiStateDisabled') { + .cuiCloseIcon { + color: tokens.$clickBadgeOpaqueColorTextDisabled; + } + } + + @include variants.variant('cuiStateWarning') { + .cuiCloseIcon { + color: tokens.$clickBadgeOpaqueColorTextWarning; + } + } + + @include variants.variant('cuiStateInfo') { + .cuiCloseIcon { + color: tokens.$clickBadgeOpaqueColorTextInfo; + } + } + } + + // Color variants for solid type + @include variants.variant('cuiTypeSolid') { + @include variants.variant('cuiStateDefault') { + .cuiCloseIcon { + color: tokens.$clickBadgeSolidColorTextDefault; + } + } + + @include variants.variant('cuiStateSuccess') { + .cuiCloseIcon { + color: tokens.$clickBadgeSolidColorTextSuccess; + } + } + + @include variants.variant('cuiStateNeutral') { + .cuiCloseIcon { + color: tokens.$clickBadgeSolidColorTextNeutral; + } + } + + @include variants.variant('cuiStateDanger') { + .cuiCloseIcon { + color: tokens.$clickBadgeSolidColorTextDanger; + } + } + + @include variants.variant('cuiStateDisabled') { + .cuiCloseIcon { + color: tokens.$clickBadgeSolidColorTextDisabled; + } + } + + @include variants.variant('cuiStateWarning') { + .cuiCloseIcon { + color: tokens.$clickBadgeSolidColorTextWarning; + } + } + + @include variants.variant('cuiStateInfo') { + .cuiCloseIcon { + color: tokens.$clickBadgeSolidColorTextInfo; + } + } + } +} + +/* Variant-specific styles for card-top-badge */ +.cuiWrapper[data-cui-variant="card-top-badge"] { + /* Position the badge at the top center of the card */ + position: absolute; + top: 0; + left: 50%; + transform: translate(-50%, -50%); +} + +/* Selected state for card-top-badge */ +.cuiWrapper[data-cui-variant="card-top-badge"][data-cui-selected="true"] { + border-color: tokens.$clickButtonBasicColorPrimaryStrokeActive; +} + +/* Active state when the sibling div is active */ +div:active + .cuiWrapper[data-cui-variant="card-top-badge"] { + border-color: tokens.$clickButtonBasicColorPrimaryStrokeActive; +} diff --git a/src/components/Badge/Badge.stories.ts b/src/components/Badge/Badge.stories.ts deleted file mode 100644 index b5d2810aa..000000000 --- a/src/components/Badge/Badge.stories.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Meta, StoryObj } from "@storybook/react-vite"; -import { Badge } from "./Badge"; - -const meta: Meta = { - component: Badge, - title: "Display/Badge", - tags: ["badge", "autodocs"], -}; - -export default meta; - -type Story = StoryObj; - -export const Playground: Story = { - args: { - text: "experiment", - state: "success", - size: "md", - type: "opaque", - }, -}; diff --git a/src/components/Badge/Badge.stories.tsx b/src/components/Badge/Badge.stories.tsx new file mode 100644 index 000000000..0405ce130 --- /dev/null +++ b/src/components/Badge/Badge.stories.tsx @@ -0,0 +1,208 @@ +import { Meta, StoryObj } from "@storybook/react-vite"; +import { Badge } from "./Badge"; + +const meta: Meta = { + component: Badge, + title: "Display/Badge", + tags: ["badge", "autodocs"], +}; + +export default meta; + +type Story = StoryObj; + +export const Playground: Story = { + args: { + text: "experiment", + state: "success", + size: "md", + type: "opaque", + }, +}; + +export const Variations: Story = { + render: () => ( +
+
+

Badge States - Opaque

+
+ + + + + + + +
+
+ +
+

Badge States - Solid

+
+ + + + + + + +
+
+ +
+

Badge Sizes

+
+ + +
+
+ +
+

With Icons

+
+ + +
+
+ +
+

Dismissible Badges

+
+ {}} + /> + {}} + /> +
+
+ +
+

Long Text

+
+ + +
+
+
+ ), + parameters: { + controls: { disable: true }, + actions: { disable: true }, + pseudo: { + hover: [".cuiWrapper"], + focus: [".cuiWrapper"], + }, + chromatic: { + delay: 300, + }, + }, +}; diff --git a/src/components/Badge/Badge.test.tsx b/src/components/Badge/Badge.test.tsx index 3a5d9fd0b..e87684e2c 100644 --- a/src/components/Badge/Badge.test.tsx +++ b/src/components/Badge/Badge.test.tsx @@ -4,7 +4,7 @@ import { renderCUI } from "@/utils/test-utils"; describe("Badge", () => { test("given a text, should render ellipsed badge", () => { const text = "text to render"; - const rendered = renderCUI(, "light"); + const rendered = renderCUI(); expect(rendered.getByText(text).textContent).toEqual(text); expect(rendered.queryByTestId("ellipsed-badge-content")).not.toBeNull(); @@ -18,8 +18,7 @@ describe("Badge", () => { , - "light" + /> ); expect(rendered.getByText(text).textContent).toEqual(text); diff --git a/src/components/Badge/Badge.tsx b/src/components/Badge/Badge.tsx index c049732d8..39820ec86 100644 --- a/src/components/Badge/Badge.tsx +++ b/src/components/Badge/Badge.tsx @@ -1,9 +1,11 @@ -import { styled } from "styled-components"; +import clsx from "clsx"; import { HorizontalDirection } from "@/components"; -import { HTMLAttributes, MouseEvent, ReactNode } from "react"; +import { ComponentPropsWithoutRef, MouseEvent, ReactNode } from "react"; import { ImageName } from "@/components/Icon/types"; import { Icon } from "@/components/Icon/Icon"; import IconWrapper from "@/components/IconWrapper/IconWrapper"; +import { capitalize } from "@/utils/capitalize"; +import styles from "./Badge.module.scss"; export type BadgeState = | "default" @@ -17,7 +19,7 @@ export type BadgeState = export type BadgeSize = "sm" | "md"; export type BadgeType = "opaque" | "solid"; -export interface CommonBadgeProps extends HTMLAttributes { +export interface CommonBadgeProps extends ComponentPropsWithoutRef<"div"> { /** The text content to display in the badge */ text: ReactNode; /** The visual state of the badge */ @@ -47,54 +49,6 @@ export interface NonDismissibleBadge extends CommonBadgeProps { onClose?: never; } -const Wrapper = styled.div<{ $state?: BadgeState; $size?: BadgeSize; $type?: BadgeType }>` - display: inline-flex; - ${({ $state = "default", $size = "md", $type = "opaque", theme }) => ` - background-color: ${theme.click.badge[$type].color.background[$state]}; - color: ${theme.click.badge[$type].color.text[$state]}; - font: ${theme.click.badge.typography.label[$size].default}; - border-radius: ${theme.click.badge.radii.all}; - border: ${theme.click.badge.stroke} solid ${theme.click.badge[$type].color.stroke[$state]}; - padding: ${theme.click.badge.space[$size].y} ${theme.click.badge.space[$size].x}; - `} -`; - -const Content = styled.div<{ $state?: BadgeState; $size?: BadgeSize }>` - display: inline-flex; - align-items: center; - gap: ${({ $size = "md", theme }) => theme.click.badge.space[$size].gap}; - max-width: 100%; - justify-content: flex-start; -`; - -const SvgContainer = styled.svg<{ - $state?: BadgeState; - $size?: BadgeSize; - $type?: BadgeType; -}>` - ${({ $state = "default", $size = "md", $type = "opaque", theme }) => ` - color: ${theme.click.badge[$type].color.text[$state]}; - height: ${theme.click.badge.icon[$size].size.height}; - width: ${theme.click.badge.icon[$size].size.width}; - `} -`; -const BadgeContent = styled.div<{ - $state?: BadgeState; - $size?: BadgeSize; - $type?: BadgeType; -}>` - width: auto; - overflow: hidden; - svg { - ${({ $state = "default", $size = "md", $type = "opaque", theme }) => ` - color: ${theme.click.badge[$type].color.text[$state]}; - height: ${theme.click.badge.icon[$size].size.height}; - width: ${theme.click.badge.icon[$size].size.width}; - gap: inherit; - `} - } -`; - export type BadgeProps = NonDismissibleBadge | DismissibleBadge; export const Badge = ({ @@ -102,39 +56,54 @@ export const Badge = ({ iconDir, text, state = "default", - size, - type, + size = "md", + type = "opaque", dismissible, onClose, ellipsisContent = true, + className, ...props -}: BadgeProps) => ( - - - - {text} - - {dismissible && ( - +}: BadgeProps) => { + const sizeClass = `cuiSize${capitalize(size)}`; + const typeClass = `cuiType${capitalize(type)}`; + const stateClass = `cuiState${capitalize(state)}`; + + return ( +
- -); + data-cui-size={size} + data-cui-type={type} + data-cui-state={state} + {...props} + > +
+ + {text} + + {dismissible && ( + + )} +
+
+ ); +}; diff --git a/src/components/BigStat/BigStat.module.scss b/src/components/BigStat/BigStat.module.scss new file mode 100644 index 000000000..f4bef4804 --- /dev/null +++ b/src/components/BigStat/BigStat.module.scss @@ -0,0 +1,129 @@ +@use "cui-mixins" as mixins; +@use "cui-variants" as variants; + +.cuiWrapper { + display: flex; + justify-content: center; + box-sizing: border-box; + border-radius: tokens.$clickBigStatRadiiAll; + border: tokens.$clickBigStatStroke solid; + padding: tokens.$clickBigStatSpaceAll; + + // State variants + @include variants.variant('cuiStateDefault') { + background-color: tokens.$clickBigStatColorBackgroundDefault; + color: tokens.$clickBigStatColorLabelDefault; + border-color: tokens.$clickBigStatColorStrokeDefault; + } + + @include variants.variant('cuiStateMuted') { + background-color: tokens.$clickBigStatColorBackgroundMuted; + color: tokens.$clickBigStatColorLabelMuted; + border-color: tokens.$clickBigStatColorStrokeMuted; + } + + @include variants.variant('cuiStateError') { + border-color: tokens.$clickBigStatColorStrokeDanger; + } + + // Size variants + @include variants.variant('cuiSizeSm') { + font: tokens.$clickBigStatTypographySmLabelDefault; + + @include variants.variant('cuiStateMuted') { + font: tokens.$clickBigStatTypographySmLabelMuted; + } + } + + @include variants.variant('cuiSizeLg') { + font: tokens.$clickBigStatTypographyLgLabelDefault; + + @include variants.variant('cuiStateMuted') { + font: tokens.$clickBigStatTypographyLgLabelMuted; + } + } + + // Spacing variants + @include variants.variant('cuiSpacingSm') { + gap: tokens.$clickBigStatSpaceSmGap; + } + + @include variants.variant('cuiSpacingLg') { + gap: tokens.$clickBigStatSpaceLgGap; + } + + // Layout variants + @include variants.variant('cuiOrderTitleTop') { + flex-direction: column; + } + + @include variants.variant('cuiOrderTitleBottom') { + flex-direction: column-reverse; + } + + @include variants.variant('cuiWidthFill') { + width: 100%; + } + + @include variants.variant('cuiWidthAuto') { + width: auto; + } +} + +.cuiLabel { + // State variants + @include variants.variant('cuiStateDefault') { + color: tokens.$clickBigStatColorLabelDefault; + + @include variants.variant('cuiSizeSm') { + font: tokens.$clickBigStatTypographySmLabelDefault; + } + + @include variants.variant('cuiSizeLg') { + font: tokens.$clickBigStatTypographyLgLabelDefault; + } + } + + @include variants.variant('cuiStateMuted') { + color: tokens.$clickBigStatColorLabelMuted; + + @include variants.variant('cuiSizeSm') { + font: tokens.$clickBigStatTypographySmLabelMuted; + } + + @include variants.variant('cuiSizeLg') { + font: tokens.$clickBigStatTypographyLgLabelMuted; + } + } + + @include variants.variant('cuiStateError') { + color: tokens.$clickBigStatColorLabelDanger; + } +} + +.cuiTitle { + // State variants + @include variants.variant('cuiStateDefault') { + color: tokens.$clickBigStatColorTitleDefault; + + @include variants.variant('cuiSizeSm') { + font: tokens.$clickBigStatTypographySmTitleDefault; + } + + @include variants.variant('cuiSizeLg') { + font: tokens.$clickBigStatTypographyLgTitleDefault; + } + } + + @include variants.variant('cuiStateMuted') { + color: tokens.$clickBigStatColorTitleMuted; + + @include variants.variant('cuiSizeSm') { + font: tokens.$clickBigStatTypographySmTitleMuted; + } + + @include variants.variant('cuiSizeLg') { + font: tokens.$clickBigStatTypographyLgTitleMuted; + } + } +} diff --git a/src/components/BigStat/BigStat.stories.ts b/src/components/BigStat/BigStat.stories.ts deleted file mode 100644 index 2f1fdf2f2..000000000 --- a/src/components/BigStat/BigStat.stories.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Meta, StoryObj } from "@storybook/react-vite"; -import { BigStat } from "./BigStat"; - -const meta: Meta = { - component: BigStat, - title: "Display/Big Stat", - tags: ["big-stat", "autodocs"], -}; - -export default meta; - -type Story = StoryObj; - -export const Playground: Story = { - args: { - label: "Percentage complete", - title: "100%", - state: "default", - size: "lg", - spacing: "sm", - order: "titleTop", - height: "", - fillWidth: false, - maxWidth: "300px", - error: false, - }, -}; diff --git a/src/components/BigStat/BigStat.stories.tsx b/src/components/BigStat/BigStat.stories.tsx new file mode 100644 index 000000000..8560d98cd --- /dev/null +++ b/src/components/BigStat/BigStat.stories.tsx @@ -0,0 +1,280 @@ +import { Meta, StoryObj } from "@storybook/react-vite"; +import { BigStat } from "./BigStat"; + +const meta: Meta = { + component: BigStat, + title: "Display/Big Stat", + tags: ["big-stat", "autodocs"], +}; + +export default meta; + +type Story = StoryObj; + +export const Playground: Story = { + args: { + label: "Percentage complete", + title: "100%", + state: "default", + size: "lg", + spacing: "sm", + order: "titleTop", + height: "", + fillWidth: false, + maxWidth: "300px", + error: false, + }, +}; + +export const Variations: Story = { + render: () => ( +
+
+

Sizes

+
+ + +
+
+ +
+

States

+
+ + + +
+
+ +
+

Spacing

+
+ + +
+
+ +
+

Order

+
+ + +
+
+ +
+

Width Options

+
+ + + +
+
+ +
+

Size & State Combinations

+
+ + + + +
+
+ +
+

Custom Heights

+
+ + + +
+
+ +
+

Complex Combinations

+
+ + + + +
+
+
+ ), + parameters: { + controls: { disable: true }, + actions: { disable: true }, + pseudo: {}, + }, +}; diff --git a/src/components/BigStat/BigStat.tsx b/src/components/BigStat/BigStat.tsx index f1befae9e..9057dba4a 100644 --- a/src/components/BigStat/BigStat.tsx +++ b/src/components/BigStat/BigStat.tsx @@ -1,11 +1,13 @@ -import { HTMLAttributes } from "react"; -import { styled } from "styled-components"; +import { ComponentPropsWithoutRef } from "react"; +import { capitalize } from "@/utils/capitalize"; +import clsx from "clsx"; +import styles from "./BigStat.module.scss"; export type bigStatOrder = "titleTop" | "titleBottom"; export type bigStatSize = "sm" | "lg"; export type bigStatSpacing = "sm" | "lg"; export type bigStatState = "default" | "muted"; -export interface BigStatProps extends Omit, "title"> { +export interface BigStatProps extends Omit, "title"> { /** Whether the component should fill the full width of its container */ fillWidth?: boolean; /** Maximum width of the component */ @@ -35,99 +37,47 @@ export const BigStat = ({ height = "6rem", label = "Label", order = "titleTop", - size, + size = "lg", spacing = "sm", state = "default", title = "Title", error = false, + style, ...props -}: BigStatProps) => ( - - - - {title} - - -); - -const Wrapper = styled.div<{ - $fillWidth?: boolean; - $maxWidth?: string; - $height?: string; - $order?: bigStatOrder; - $size?: bigStatSize; - $spacing?: bigStatSpacing; - $state?: bigStatState; - $error?: boolean; -}>` - display: flex; - justify-content: center; - box-sizing: border-box; - ${({ - $fillWidth = false, - $maxWidth = "none", - $state = "default", - $size = "lg", - $height = "fixed", - $order, - $spacing = "sm", - $error = false, - theme, - }) => ` - background-color: ${theme.click.bigStat.color.background[$state]}; - color: ${theme.click.bigStat.color.label[$state]}; - font: ${theme.click.bigStat.typography[$size].label[$state]}; - border-radius: ${theme.click.bigStat.radii.all}; - border: ${theme.click.bigStat.stroke} solid ${ - $error - ? theme.click.bigStat.color.stroke.danger - : theme.click.bigStat.color.stroke[$state] - }; - gap: ${theme.click.bigStat.space[$spacing].gap}; - padding: ${theme.click.bigStat.space.all}; - min-height: ${$height !== undefined ? `${$height}` : "auto"}; - flex-direction: ${$order === "titleBottom" ? "column-reverse" : "column"}; - width: ${$fillWidth === true ? "100%" : "auto"}; - max-width: ${$maxWidth ? $maxWidth : "none"}; - `} -`; +}: BigStatProps) => { + const stateClass = error ? "cuiStateError" : `cuiState${capitalize(state)}`; + const sizeClass = `cuiSize${capitalize(size)}`; + const spacingClass = `cuiSpacing${capitalize(spacing)}`; + const orderClass = `cuiOrder${capitalize(order)}`; + const widthClass = fillWidth ? "cuiWidthFill" : "cuiWidthAuto"; -const Label = styled.div<{ - $state?: bigStatState; - $size?: bigStatSize; - $error?: boolean; -}>` - ${({ $state = "default", $size = "lg", $error = false, theme }) => ` - color: ${$error ? theme.click.bigStat.color.label.danger : theme.click.bigStat.color.label[$state]}; - font: ${theme.click.bigStat.typography[$size].label[$state]}; - `} -`; - -const Title = styled.div<{ - $state?: bigStatState; - $size?: bigStatSize; -}>` - ${({ $state = "default", $size = "lg", theme }) => ` - color: ${theme.click.bigStat.color.title[$state]}; - font: ${theme.click.bigStat.typography[$size].title[$state]}; - `} -`; + return ( +
+
+ {label} +
+
+ {title} +
+
+ ); +}; diff --git a/src/components/Button/Button.module.scss b/src/components/Button/Button.module.scss new file mode 100644 index 000000000..e4e4afc01 --- /dev/null +++ b/src/components/Button/Button.module.scss @@ -0,0 +1,237 @@ +@use "cui-mixins" as mixins; +@use "cui-variants" as variants; + +@keyframes shimmerFullWidth { + 0% { + background-position: 100% 0; + } + 100% { + background-position: -100% 0; + } +} + +@keyframes shimmerFixedWidth { + 0% { + background-position: -200px 0; + } + 100% { + background-position: 200px 0; + } +} + +.cuiButton { + @include mixins.cuiBaseButton; + position: relative; + white-space: nowrap; + overflow: hidden; + flex-shrink: 0; + + &:hover { + transition: var(--transition-default); + } + + &::before { + content: none; + position: absolute; + inset: 0; + pointer-events: none; + } + + // Button type variants - using :where() for low specificity (0,0,1,0) + @include variants.variant('cuiPrimary') { + color: tokens.$clickButtonBasicColorPrimaryTextDefault; + background-color: tokens.$clickButtonBasicColorPrimaryBackgroundDefault; + border: tokens.$clickButtonStroke solid tokens.$clickButtonBasicColorPrimaryStrokeDefault; + + &:hover { + background-color: tokens.$clickButtonBasicColorPrimaryBackgroundHover; + border: tokens.$clickButtonStroke solid tokens.$clickButtonBasicColorPrimaryStrokeHover; + font: tokens.$clickButtonBasicTypographyLabelHover; + } + + &:active, + &:focus { + background-color: tokens.$clickButtonBasicColorPrimaryBackgroundActive; + border: 1px solid tokens.$clickButtonBasicColorPrimaryStrokeActive; + font: tokens.$clickButtonBasicTypographyLabelActive; + } + + &:disabled:not(.cuiLoading), + &:disabled:not(.cuiLoading):hover, + &:disabled:not(.cuiLoading):active { + background-color: tokens.$clickButtonBasicColorPrimaryBackgroundDisabled; + color: tokens.$clickButtonBasicColorPrimaryTextDisabled; + border: tokens.$clickButtonStroke solid tokens.$clickButtonBasicColorPrimaryStrokeDisabled; + font: tokens.$clickButtonBasicTypographyLabelDisabled; + cursor: not-allowed; + } + + &.cuiLoading::before { + content: ""; + background: tokens.$clickButtonBasicColorPrimaryBackgroundLoading; + } + } + + @include variants.variant('cuiSecondary') { + color: tokens.$clickButtonBasicColorSecondaryTextDefault; + background-color: tokens.$clickButtonBasicColorSecondaryBackgroundDefault; + border: tokens.$clickButtonStroke solid tokens.$clickButtonBasicColorSecondaryStrokeDefault; + + &:hover { + background-color: tokens.$clickButtonBasicColorSecondaryBackgroundHover; + border: tokens.$clickButtonStroke solid tokens.$clickButtonBasicColorSecondaryStrokeHover; + font: tokens.$clickButtonBasicTypographyLabelHover; + } + + &:active, + &:focus { + background-color: tokens.$clickButtonBasicColorSecondaryBackgroundActive; + border: 1px solid tokens.$clickButtonBasicColorSecondaryStrokeActive; + font: tokens.$clickButtonBasicTypographyLabelActive; + } + + &:disabled:not(.cuiLoading), + &:disabled:not(.cuiLoading):hover, + &:disabled:not(.cuiLoading):active { + background-color: tokens.$clickButtonBasicColorSecondaryBackgroundDisabled; + color: tokens.$clickButtonBasicColorSecondaryTextDisabled; + border: tokens.$clickButtonStroke solid tokens.$clickButtonBasicColorSecondaryStrokeDisabled; + font: tokens.$clickButtonBasicTypographyLabelDisabled; + cursor: not-allowed; + } + + &.cuiLoading::before { + content: ""; + background: tokens.$clickButtonBasicColorSecondaryBackgroundLoading; + } + } + + @include variants.variant('cuiEmpty') { + color: tokens.$clickButtonBasicColorEmptyTextDefault; + background-color: tokens.$clickButtonBasicColorEmptyBackgroundDefault; + border: tokens.$clickButtonStroke solid tokens.$clickButtonBasicColorEmptyStrokeDefault; + + &:hover { + background-color: tokens.$clickButtonBasicColorEmptyBackgroundHover; + border: tokens.$clickButtonStroke solid tokens.$clickButtonBasicColorEmptyStrokeHover; + font: tokens.$clickButtonBasicTypographyLabelHover; + } + + &:active, + &:focus { + background-color: tokens.$clickButtonBasicColorEmptyBackgroundActive; + border: 1px solid tokens.$clickButtonBasicColorEmptyStrokeActive; + font: tokens.$clickButtonBasicTypographyLabelActive; + } + + &:disabled:not(.cuiLoading), + &:disabled:not(.cuiLoading):hover, + &:disabled:not(.cuiLoading):active { + background-color: tokens.$clickButtonBasicColorEmptyBackgroundDisabled; + color: tokens.$clickButtonBasicColorEmptyTextDisabled; + border: tokens.$clickButtonStroke solid tokens.$clickButtonBasicColorEmptyStrokeDisabled; + font: tokens.$clickButtonBasicTypographyLabelDisabled; + cursor: not-allowed; + } + + &.cuiLoading { + cursor: not-allowed; + opacity: 0.9; + + > * { + opacity: 0.7; + } + + &::before { + content: ""; + background: tokens.$clickButtonBasicColorEmptyBackgroundLoading; + } + } + } + + @include variants.variant('cuiDanger') { + color: tokens.$clickButtonBasicColorDangerTextDefault; + background-color: tokens.$clickButtonBasicColorDangerBackgroundDefault; + border: tokens.$clickButtonStroke solid tokens.$clickButtonBasicColorDangerStrokeDefault; + + &:hover { + background-color: tokens.$clickButtonBasicColorDangerBackgroundHover; + border: tokens.$clickButtonStroke solid tokens.$clickButtonBasicColorDangerStrokeHover; + font: tokens.$clickButtonBasicTypographyLabelHover; + } + + &:active, + &:focus { + background-color: tokens.$clickButtonBasicColorDangerBackgroundActive; + border: 1px solid tokens.$clickButtonBasicColorDangerStrokeActive; + font: tokens.$clickButtonBasicTypographyLabelActive; + } + + &:disabled:not(.cuiLoading), + &:disabled:not(.cuiLoading):hover, + &:disabled:not(.cuiLoading):active { + background-color: tokens.$clickButtonBasicColorDangerBackgroundDisabled; + color: tokens.$clickButtonBasicColorDangerTextDisabled; + border: tokens.$clickButtonStroke solid tokens.$clickButtonBasicColorDangerStrokeDisabled; + font: tokens.$clickButtonBasicTypographyLabelDisabled; + cursor: not-allowed; + } + + &.cuiLoading::before { + content: ""; + background: tokens.$clickButtonBasicColorDangerBackgroundLoading; + } + } + + // Alignment variants - using :where() for low specificity + @include variants.variant('cuiAlignLeft') { + justify-content: flex-start; + } + + @include variants.variant('cuiAlignCenter') { + justify-content: center; + } + + // Fill width variant - using :where() for low specificity + @include variants.variant('cuiFillWidth') { + width: 100%; + } + + // Loading state + &.cuiLoading { + cursor: not-allowed; + + // Default opacity for most button types + &:not(.cuiEmpty) { + opacity: 0.7; + + > * { + opacity: 0.7; + } + } + + &::before { + background-size: 200px 100%; + background-repeat: no-repeat; + animation: shimmerFixedWidth 1.5s ease-in-out infinite; + } + + &.cuiFillWidth::before { + background-size: 200% 100%; + background-repeat: repeat; + animation: shimmerFullWidth 1.5s ease-in-out infinite; + } + } +} + +// Button icon styling +.cuiButtonIcon { + composes: cuiIconWrapper from '../Icon/Icon.module.scss'; + height: tokens.$clickButtonBasicSizeIconAll; + width: tokens.$clickButtonBasicSizeIconAll; + + svg { + height: tokens.$clickButtonBasicSizeIconAll; + width: tokens.$clickButtonBasicSizeIconAll; + } +} diff --git a/src/components/Button/Button.stories.ts b/src/components/Button/Button.stories.ts deleted file mode 100644 index 83275b69c..000000000 --- a/src/components/Button/Button.stories.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Meta, StoryObj } from "@storybook/react-vite"; -import { Button } from "./Button"; - -const meta: Meta = { - component: Button, - title: "Buttons/Button", - tags: ["button", "autodocs"], -}; - -export default meta; - -type Story = StoryObj; - -export const Playground: Story = { - args: { - type: "primary", - disabled: false, - label: "Button", - align: "center", - fillWidth: false, - loading: false, - }, -}; diff --git a/src/components/Button/Button.stories.tsx b/src/components/Button/Button.stories.tsx new file mode 100644 index 000000000..c208e6fc9 --- /dev/null +++ b/src/components/Button/Button.stories.tsx @@ -0,0 +1,159 @@ +import { Meta, StoryObj } from "@storybook/react-vite"; +import { Button } from "./Button"; + +const meta: Meta = { + component: Button, + title: "Buttons/Button", + tags: ["button", "autodocs"], +}; + +export default meta; + +type Story = StoryObj; + +export const Playground: Story = { + args: { + type: "primary", + disabled: false, + label: "Button", + align: "center", + fillWidth: false, + loading: false, + }, +}; + +export const Variations: Story = { + parameters: { + controls: { disable: true }, + actions: { disable: true }, + pseudo: { + hover: [".cuiButton"], + focus: [".cuiButton"], + active: [".cuiButton"], + }, + chromatic: { + delay: 300, + }, + }, + + render: () => ( +
+
+

Button Types

+
+
+
+ +
+

States

+
+
+
+ +
+

With Icons

+
+
+
+ +
+

Alignment & Width

+
+
+
+ +
+

All Types - Disabled

+
+
+
+
+ ), +}; diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx index 6b4487e57..21c5f2636 100644 --- a/src/components/Button/Button.tsx +++ b/src/components/Button/Button.tsx @@ -1,12 +1,15 @@ +"use client"; + import { Icon, IconName } from "@/components"; -import { styled, keyframes } from "styled-components"; -import { BaseButton } from "../commonElement"; +import clsx from "clsx"; +import { capitalize } from "../../utils/capitalize"; +import styles from "./Button.module.scss"; import React from "react"; export type ButtonType = "primary" | "secondary" | "empty" | "danger"; type Alignment = "center" | "left"; -export interface ButtonProps extends React.HTMLAttributes { +export interface ButtonProps extends Omit, "type"> { /** The visual style variant of the button */ type?: ButtonType; /** Whether the button is disabled */ @@ -37,171 +40,49 @@ export const Button = ({ label, loading = false, disabled, + className, ...delegated -}: ButtonProps) => ( - - {iconLeft && ( - - )} - - {label ?? children} - - {iconRight && ( - - )} - -); - -const shimmerFullWidth = keyframes({ - "0%": { - backgroundPosition: "100% 0", - }, - "100%": { - backgroundPosition: "-100% 0", - }, -}); - -const shimmerFixedWidth = keyframes({ - "0%": { - backgroundPosition: "-200px 0", - }, - "100%": { - backgroundPosition: "200px 0", - }, -}); - -const StyledButton = styled(BaseButton)<{ - $styleType: ButtonType; - $align?: Alignment; - $fillWidth?: boolean; - $loading?: boolean; -}>` - width: ${({ $fillWidth }) => ($fillWidth ? "100%" : "revert")}; - color: ${({ $styleType = "primary", theme }) => - theme.click.button.basic.color[$styleType].text.default}; - background-color: ${({ $styleType = "primary", theme }) => - theme.click.button.basic.color[$styleType].background.default}; - border: ${({ theme }) => theme.click.button.stroke} solid - ${({ $styleType = "primary", theme }) => - theme.click.button.basic.color[$styleType].stroke.default}; - font: ${({ theme }) => theme.click.button.basic.typography.label.default}; - position: relative; - display: flex; - align-items: center; - justify-content: ${({ $align }) => ($align === "left" ? "flex-start" : "center")}; - white-space: nowrap; - overflow: hidden; - - &::before { - content: ${({ $loading }) => ($loading ? '""' : "none")}; - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - pointer-events: none; - background: ${({ $styleType, theme }) => - theme.click.button.basic.color[$styleType].background.loading}; - background-size: ${({ $fillWidth }) => ($fillWidth ? "200% 100%" : "200px 100%")}; - background-repeat: ${({ $fillWidth }) => ($fillWidth ? "repeat" : "no-repeat")}; - } - - &[data-fill-width="true"]::before { - animation: ${shimmerFullWidth} 1.5s ease-in-out infinite; - } - - &[data-fill-width="false"]::before, - &:not([data-fill-width])::before { - animation: ${shimmerFixedWidth} 1.5s ease-in-out infinite; - } - - &:hover { - background-color: ${({ $styleType = "primary", theme }) => - theme.click.button.basic.color[$styleType].background.hover}; - border: ${({ theme }) => theme.click.button.stroke} solid - ${({ $styleType = "primary", theme }) => - theme.click.button.basic.color[$styleType].stroke.hover}; - transition: ${({ theme }) => theme.transition.default}; - font: ${({ theme }) => theme.click.button.basic.typography.label.hover}; - } - - &:active, - &:focus { - background-color: ${({ $styleType = "primary", theme }) => - theme.click.button.basic.color[$styleType].background.active}; - border: 1px solid - ${({ $styleType = "primary", theme }) => - theme.click.button.basic.color[$styleType].stroke.active}; - font: ${({ theme }) => theme.click.button.basic.typography.label.active}; - } - - ${({ $loading, $styleType, theme }) => { - if ($loading) { - return ""; - } - - const bgDisabled = theme.click.button.basic.color[$styleType].background.disabled; - const textDisabled = theme.click.button.basic.color[$styleType].text.disabled; - const strokeDisabled = theme.click.button.basic.color[$styleType].stroke.disabled; - const stroke = theme.click.button.stroke; - const fontDisabled = theme.click.button.basic.typography.label.disabled; - - return ` - &:disabled, - &:disabled:hover, - &:disabled:active { - background-color: ${bgDisabled}; - color: ${textDisabled}; - border: ${stroke} solid ${strokeDisabled}; - font: ${fontDisabled}; - cursor: not-allowed; - } - `; - }} - - /* Loading state styling */ - ${({ $loading, $styleType }) => { - if (!$loading) { - return ""; - } - - const btnOpacity = $styleType === "empty" ? 0.9 : 0.7; - - return ` - cursor: not-allowed; - opacity: ${btnOpacity}; - - /* Dim text and icons */ - > * { - opacity: 0.7; - } - `; - }} -`; - -const ButtonIcon = styled(Icon)` - height: ${({ theme }) => theme.click.button.basic.size.icon.all}; - width: ${({ theme }) => theme.click.button.basic.size.icon.all}; - svg { - height: ${({ theme }) => theme.click.button.basic.size.icon.all}; - width: ${({ theme }) => theme.click.button.basic.size.icon.all}; - } -`; +}: ButtonProps) => { + const typeClass = `cui${capitalize(type)}`; + const alignClass = `cuiAlign${capitalize(align)}`; + + return ( + + ); +}; diff --git a/src/components/Button/_mixins.scss b/src/components/Button/_mixins.scss new file mode 100644 index 000000000..7d9d38189 --- /dev/null +++ b/src/components/Button/_mixins.scss @@ -0,0 +1,115 @@ +@use "../../styles/tokens-light-dark" as tokens; + +// Button mixins for Click UI Button components + +// Base button mixin - minimal styling for button element +@mixin cuiButtonBase { + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + border-radius: tokens.$clickButtonBasicRadiiDefault; + cursor: pointer; + text-decoration: none; + transition: tokens.$clickButtonBasicTransitionsAll; + white-space: nowrap; + position: relative; + + &:focus { + outline: none; + } + + &:disabled { + pointer-events: none; + opacity: 0.6; + } +} + +// Button size variants - uses direct SCSS tokens (no CSS variables) +@mixin cuiButtonSize($size) { + padding: tokens.$clickButtonBasicSpaceY tokens.$clickButtonBasicSpaceX; + gap: tokens.$clickButtonBasicSpaceGap; + font: tokens.$clickButtonBasicTypographyLabelDefault; + min-height: tokens.$clickButtonBasicSizeIconHeight; + + // Note: If you need size-specific tokens, they should be generated in the token system + // and called directly in the component module, not through a dynamic mixin parameter +} + +// Button type/color variants - uses direct SCSS tokens (no CSS variables) +@mixin cuiButtonType($type, $state: "default") { + @if $type == 'primary' { + @if $state == 'default' { + background-color: tokens.$clickButtonBasicColorPrimaryBackgroundDefault; + color: tokens.$clickButtonBasicColorPrimaryTextDefault; + border: 1px solid tokens.$clickButtonBasicColorPrimaryStrokeDefault; + } @else if $state == 'hover' { + background-color: tokens.$clickButtonBasicColorPrimaryBackgroundHover; + color: tokens.$clickButtonBasicColorPrimaryTextHover; + border: 1px solid tokens.$clickButtonBasicColorPrimaryStrokeHover; + } @else if $state == 'active' { + background-color: tokens.$clickButtonBasicColorPrimaryBackgroundActive; + color: tokens.$clickButtonBasicColorPrimaryTextActive; + border: 1px solid tokens.$clickButtonBasicColorPrimaryStrokeActive; + } + } @else if $type == 'secondary' { + @if $state == 'default' { + background-color: tokens.$clickButtonBasicColorSecondaryBackgroundDefault; + color: tokens.$clickButtonBasicColorSecondaryTextDefault; + border: 1px solid tokens.$clickButtonBasicColorSecondaryStrokeDefault; + } @else if $state == 'hover' { + background-color: tokens.$clickButtonBasicColorSecondaryBackgroundHover; + color: tokens.$clickButtonBasicColorSecondaryTextHover; + border: 1px solid tokens.$clickButtonBasicColorSecondaryStrokeHover; + } @else if $state == 'active' { + background-color: tokens.$clickButtonBasicColorSecondaryBackgroundActive; + color: tokens.$clickButtonBasicColorSecondaryTextActive; + border: 1px solid tokens.$clickButtonBasicColorSecondaryStrokeActive; + } + } +} + +// Empty button mixin - for unstyled buttons +@mixin cuiEmptyButton { + background: transparent; + cursor: pointer; + outline: none; + padding: 0; + border: 0; + color: inherit; + font: inherit; + + &:disabled { + cursor: not-allowed; + } +} + +// Base button mixin - for styled buttons with common base styles +@mixin cuiBaseButton { + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + cursor: pointer; + padding: tokens.$clickButtonBasicSpaceY tokens.$clickButtonBasicSpaceX; + border-radius: tokens.$clickButtonRadiiAll; + gap: tokens.$clickButtonBasicSpaceGap; + font: tokens.$clickButtonBasicTypographyLabelDefault; + + &:hover { + font: tokens.$clickButtonBasicTypographyLabelHover; + } + + &:active, + &:focus { + outline: none; + font: tokens.$clickButtonBasicTypographyLabelActive; + } + + &:disabled, + &:disabled:hover, + &:disabled:active { + font: tokens.$clickButtonBasicTypographyLabelDisabled; + cursor: not-allowed; + } +} diff --git a/src/components/ButtonGroup/ButtonGroup.module.scss b/src/components/ButtonGroup/ButtonGroup.module.scss new file mode 100644 index 000000000..820553f78 --- /dev/null +++ b/src/components/ButtonGroup/ButtonGroup.module.scss @@ -0,0 +1,103 @@ +@use "cui-mixins" as mixins; +@use "cui-variants" as variants; + +.cuiButtonGroupWrapper { + display: inline-flex; + box-sizing: border-box; + flex-direction: row; + justify-content: center; + align-items: center; + background: tokens.$clickButtonGroupColorBackgroundPanel; + border-radius: tokens.$clickButtonGroupRadiiPanelAll; + + &.cuiFillWidth { + width: 100%; + } + + // Type variants + @include variants.variant('cuiTypeDefault') { + padding: tokens.$clickButtonGroupSpacePanelDefaultX tokens.$clickButtonGroupSpacePanelDefaultY; + gap: tokens.$clickButtonGroupSpacePanelDefaultGap; + border: 1px solid tokens.$clickButtonGroupColorPanelStrokeDefault; + } + + @include variants.variant('cuiTypeBorderless') { + padding: tokens.$clickButtonGroupSpacePanelBorderlessX tokens.$clickButtonGroupSpacePanelBorderlessY; + gap: tokens.$clickButtonGroupSpacePanelBorderlessGap; + border: none; + } +} + +.cuiButton { + box-sizing: border-box; + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + color: tokens.$clickButtonGroupColorTextDefault; + font: tokens.$clickButtonGroupTypographyLabelDefault; + cursor: pointer; + border: none; + + &.cuiFillWidth { + flex: 1; + } + + // Active/inactive background states + &.cuiActive { + background: tokens.$clickButtonGroupColorBackgroundActive; + } + + &.cuiInactive { + background: tokens.$clickButtonGroupColorBackgroundDefault; + } + + // Type variants + @include variants.variant('cuiTypeDefault') { + padding: tokens.$clickButtonGroupSpaceButtonDefaultY tokens.$clickButtonGroupSpaceButtonDefaultX; + border-radius: tokens.$clickButtonGroupRadiiButtonDefaultAll; + } + + @include variants.variant('cuiTypeBorderless') { + padding: tokens.$clickButtonGroupSpaceButtonBorderlessY tokens.$clickButtonGroupSpaceButtonBorderlessX; + border-radius: tokens.$clickButtonGroupRadiiButtonBorderlessAll; + } + + &:hover:not(:disabled) { + background: tokens.$clickButtonGroupColorBackgroundHover; + font: tokens.$clickButtonGroupTypographyLabelHover; + color: tokens.$clickButtonGroupColorTextHover; + } + + &:disabled { + cursor: not-allowed; + font: tokens.$clickButtonGroupTypographyLabelDisabled; + color: tokens.$clickButtonGroupColorTextDisabled; + + &.cuiActive { + background: tokens.$clickButtonGroupColorBackgroundDisabled; + } + + &.cuiInactive { + background: tokens.$clickButtonGroupColorBackgroundDisabled; + } + + &:active, + &:focus, + &[aria-pressed="true"] { + color: tokens.$clickButtonGroupColorTextDisabled; + } + } + + &:active:not(:disabled), + &:focus:not(:disabled), + &[aria-pressed="true"]:not(:disabled) { + background: tokens.$clickButtonGroupColorBackgroundActive; + font: tokens.$clickButtonGroupTypographyLabelActive; + color: tokens.$clickButtonGroupColorTextActive; + + &:disabled { + background: tokens.$clickButtonGroupColorBackgroundDisabled; + } + } +} diff --git a/src/components/ButtonGroup/ButtonGroup.stories.tsx b/src/components/ButtonGroup/ButtonGroup.stories.tsx index a84b47401..db869a048 100644 --- a/src/components/ButtonGroup/ButtonGroup.stories.tsx +++ b/src/components/ButtonGroup/ButtonGroup.stories.tsx @@ -35,3 +35,152 @@ export const Playground: StoryObj = { selected: "option3", }, }; + +export const Variations: StoryObj = { + render: () => ( +
+
+

Type: Default

+
+ + +
+
+ +
+

Type: Borderless

+
+ + +
+
+ +
+

Fill Width

+
+ + +
+
+ +
+

Many Options

+
+ +
+
+ +
+

Disabled State

+
+ + +
+
+ +
+

No Selection

+
+ +
+
+
+ ), + parameters: { + controls: { disable: true }, + actions: { disable: true }, + pseudo: { + hover: [".cuiButton"], + focus: [".cuiButton"], + active: [".cuiButton"], + }, + chromatic: { + delay: 300, + }, + }, +}; diff --git a/src/components/ButtonGroup/ButtonGroup.tsx b/src/components/ButtonGroup/ButtonGroup.tsx index 0a2e4edb3..9224ae974 100644 --- a/src/components/ButtonGroup/ButtonGroup.tsx +++ b/src/components/ButtonGroup/ButtonGroup.tsx @@ -1,20 +1,26 @@ -import { HTMLAttributes, ReactNode } from "react"; -import { DefaultTheme, styled } from "styled-components"; +"use client"; + +import { ComponentPropsWithoutRef, ReactNode } from "react"; +import clsx from "clsx"; +import { capitalize } from "@/utils/capitalize"; +import styles from "@/components/ButtonGroup/ButtonGroup.module.scss"; type ButtonGroupType = "default" | "borderless"; export interface ButtonGroupElementProps extends Omit< - HTMLAttributes, + ComponentPropsWithoutRef<"button">, "children" > { /** The unique value for this button */ value: string; /** The label text to display */ label?: ReactNode; + /** Whether the button is disabled */ + disabled?: boolean; } export interface ButtonGroupProps extends Omit< - HTMLAttributes, + ComponentPropsWithoutRef<"div">, "onClick" > { /** Array of button options to display */ @@ -35,111 +41,43 @@ export const ButtonGroup = ({ fillWidth = false, onClick, type = "default", + className, ...props }: ButtonGroupProps) => { - const buttons = options.map(({ value, label, ...props }) => ( - + )); return ( - {buttons} - + ); }; - -const ButtonGroupWrapper = styled.div<{ $fillWidth: boolean; $type: ButtonGroupType }>` - display: inline-flex; - box-sizing: border-box; - flex-direction: row; - justify-content: center; - align-items: center; - padding: ${({ theme, $type }) => - `${theme.click.button.group.space.panel[$type].x} ${theme.click.button.group.space.panel[$type].y}`}; - gap: ${({ theme, $type }) => theme.click.button.group.space.panel[$type].gap}; - border: ${({ theme, $type }) => - $type === "default" - ? `1px solid ${theme.click.button.group.color.panel.stroke[$type]}` - : "none"}; - background: ${({ theme }) => theme.click.button.group.color.background.panel}; - border-radius: ${({ theme }) => theme.click.button.group.radii.panel.all}; - width: ${({ $fillWidth }) => ($fillWidth ? "100%" : "auto")}; -`; - -interface ButtonProps { - $active: boolean; - theme: DefaultTheme; - $fillWidth: boolean; - $type: ButtonGroupType; -} - -const Button = styled.button.attrs((props: ButtonProps) => ({ - "aria-pressed": props.$active, -}))` - box-sizing: border-box; - display: flex; - flex-direction: row; - justify-content: center; - align-items: center; - background: ${({ $active, theme }: ButtonProps) => - $active - ? theme.click.button.group.color.background.active - : theme.click.button.group.color.background.default}; - color: ${({ theme }) => theme.click.button.group.color.text.default}; - font: ${({ theme }) => theme.click.button.group.typography.label.default}; - padding: ${({ theme, $type }) => - `${theme.click.button.group.space.button[$type].y} ${theme.click.button.group.space.button[$type].x}`}; - ${({ $fillWidth }) => ($fillWidth ? "flex: 1;" : "")}; - border-radius: ${({ theme, $type }) => - theme.click.button.group.radii.button[$type].all}; - cursor: pointer; - border: none; - - &:hover { - background: ${({ theme }) => theme.click.button.group.color.background.hover}; - font: ${({ theme }) => theme.click.button.group.typography.label.hover}; - color: ${({ theme }) => theme.click.button.group.color.text.hover}; - } - - &:disabled { - cursor: not-allowed; - font: ${({ theme }) => theme.click.button.group.typography.label.disabled}; - color: ${({ theme }) => theme.click.button.group.color.text.disabled}; - background: ${({ theme, $active }) => - theme.click.button.group.color.background[ - $active ? "disabled-active" : "disabled" - ]}; - - &:active, - &:focus, - &[aria-pressed="true"] { - color: ${({ theme }) => theme.click.button.group.color.text.disabled}; - } - } - - &:active, - &:focus, - &[aria-pressed="true"] { - background: ${({ theme }) => theme.click.button.group.color.background.active}; - font: ${({ theme }) => theme.click.button.group.typography.label.active}; - color: ${({ theme }) => theme.click.button.group.color.text.active}; - &:disabled { - background: ${({ theme }) => - theme.click.button.group.color.background["disabled-active"]}; - } - } -`; diff --git a/src/components/CardHorizontal/CardHorizontal.module.scss b/src/components/CardHorizontal/CardHorizontal.module.scss new file mode 100644 index 000000000..eed514ddc --- /dev/null +++ b/src/components/CardHorizontal/CardHorizontal.module.scss @@ -0,0 +1,288 @@ +@use "cui-mixins" as mixins; +@use "cui-variants" as variants; + +.cuiWrapper { + @include mixins.cuiCardBase; + + display: inline-flex; + width: 100%; + max-width: 100%; + align-items: center; + justify-content: flex-start; + border-radius: tokens.$clickCardHorizontalRadiiAll; + transition: all 0.2s ease-in-out; + + // Size variants + @include variants.variant('cuiSizeMd') { + padding: tokens.$clickCardHorizontalSpaceMdY tokens.$clickCardHorizontalSpaceMdX; + gap: tokens.$clickCardHorizontalSpaceMdGap; + + .cuiDescription { + gap: tokens.$clickCardHorizontalSpaceMdGap; + } + } + + @include variants.variant('cuiSizeSm') { + padding: tokens.$clickCardHorizontalSpaceSmY tokens.$clickCardHorizontalSpaceSmX; + gap: tokens.$clickCardHorizontalSpaceSmGap; + + .cuiDescription { + gap: tokens.$clickCardHorizontalSpaceSmGap; + } + } + + .cuiDescription { + display: flex; + flex-direction: column; + align-self: start; + flex: 1; + width: 100%; + } + + // Color variant: default + @include variants.variant('cuiColorDefault') { + background: tokens.$clickCardHorizontalDefaultColorBackgroundDefault; + color: tokens.$clickCardHorizontalDefaultColorTitleDefault; + border: 1px solid tokens.$clickCardHorizontalDefaultColorStrokeDefault; + font: tokens.$clickCardHorizontalTypographyTitleDefault; + + .cuiDescription { + color: tokens.$clickCardHorizontalDefaultColorDescriptionDefault; + font: tokens.$clickCardHorizontalTypographyDescriptionDefault; + } + + // Selectable hover state + &.cuiIsSelectable:hover { + background-color: tokens.$clickCardHorizontalDefaultColorBackgroundHover; + color: tokens.$clickCardHorizontalDefaultColorTitleHover; + border: 1px solid tokens.$clickCardHorizontalDefaultColorStrokeDefault; + cursor: pointer; + font: tokens.$clickCardHorizontalTypographyTitleHover; + + .cuiDescription { + color: tokens.$clickCardHorizontalDefaultColorDescriptionHover; + font: tokens.$clickCardHorizontalTypographyDescriptionHover; + } + } + + // Selectable hover + selected + &.cuiIsSelectable:hover { + @include variants.variant('cuiSelected') { + border: 1px solid tokens.$clickCardHorizontalDefaultColorStrokeActive; + } + } + + // Selectable active/focus states + &.cuiIsSelectable:active, + &.cuiIsSelectable:focus, + &.cuiIsSelectable:focus-within { + background-color: tokens.$clickCardHorizontalDefaultColorBackgroundActive; + color: tokens.$clickCardHorizontalDefaultColorTitleActive; + border: 1px solid tokens.$clickCardHorizontalDefaultColorStrokeActive; + + .cuiDescription { + color: tokens.$clickCardHorizontalDefaultColorDescriptionActive; + font: tokens.$clickCardHorizontalTypographyDescriptionActive; + } + } + + // Selected state + @include variants.variant('cuiSelected') { + border: 1px solid tokens.$clickCardHorizontalDefaultColorStrokeActive; + + &:hover { + border: 1px solid tokens.$clickCardHorizontalDefaultColorStrokeActive; + } + } + + // Disabled state + @include variants.variant('cuiDisabled') { + pointer-events: none; + background-color: tokens.$clickCardHorizontalDefaultColorBackgroundDisabled; + color: tokens.$clickCardHorizontalDefaultColorTitleDisabled; + border: 1px solid tokens.$clickCardHorizontalDefaultColorStrokeDisabled; + cursor: not-allowed; + + .cuiDescription { + color: tokens.$clickCardHorizontalDefaultColorDescriptionDisabled; + font: tokens.$clickCardHorizontalTypographyDescriptionDisabled; + } + + &:hover, + &:active, + &:focus, + &:focus-within { + background-color: tokens.$clickCardHorizontalDefaultColorBackgroundDisabled; + color: tokens.$clickCardHorizontalDefaultColorTitleDisabled; + cursor: not-allowed; + + .cuiDescription { + color: tokens.$clickCardHorizontalDefaultColorDescriptionDisabled; + font: tokens.$clickCardHorizontalTypographyDescriptionDisabled; + } + } + + @include variants.variant('cuiSelected') { + border: 1px solid tokens.$clickCardHorizontalDefaultColorStrokeActive; + + &:active, + &:focus, + &:focus-within { + border: 1px solid tokens.$clickCardHorizontalDefaultColorStrokeActive; + } + } + } + } + + // Color variant: muted + @include variants.variant('cuiColorMuted') { + background: tokens.$clickCardHorizontalMutedColorBackgroundDefault; + color: tokens.$clickCardHorizontalMutedColorTitleDefault; + border: 1px solid tokens.$clickCardHorizontalMutedColorStrokeDefault; + font: tokens.$clickCardHorizontalTypographyTitleDefault; + + .cuiDescription { + color: tokens.$clickCardHorizontalMutedColorDescriptionDefault; + font: tokens.$clickCardHorizontalTypographyDescriptionDefault; + } + + // Selectable hover state + &.cuiIsSelectable:hover { + background-color: tokens.$clickCardHorizontalMutedColorBackgroundHover; + color: tokens.$clickCardHorizontalMutedColorTitleHover; + border: 1px solid tokens.$clickCardHorizontalMutedColorStrokeDefault; + cursor: pointer; + font: tokens.$clickCardHorizontalTypographyTitleHover; + + .cuiDescription { + color: tokens.$clickCardHorizontalMutedColorDescriptionHover; + font: tokens.$clickCardHorizontalTypographyDescriptionHover; + } + } + + // Selectable hover + selected + &.cuiIsSelectable:hover { + @include variants.variant('cuiSelected') { + border: 1px solid tokens.$clickCardHorizontalMutedColorStrokeActive; + } + } + + // Selectable active/focus states + &.cuiIsSelectable:active, + &.cuiIsSelectable:focus, + &.cuiIsSelectable:focus-within { + background-color: tokens.$clickCardHorizontalMutedColorBackgroundActive; + color: tokens.$clickCardHorizontalMutedColorTitleActive; + border: 1px solid tokens.$clickCardHorizontalMutedColorStrokeActive; + + .cuiDescription { + color: tokens.$clickCardHorizontalMutedColorDescriptionActive; + font: tokens.$clickCardHorizontalTypographyDescriptionActive; + } + } + + // Selected state + @include variants.variant('cuiSelected') { + border: 1px solid tokens.$clickCardHorizontalMutedColorStrokeActive; + + &:hover { + border: 1px solid tokens.$clickCardHorizontalMutedColorStrokeActive; + } + } + + // Disabled state + @include variants.variant('cuiDisabled') { + pointer-events: none; + background-color: tokens.$clickCardHorizontalMutedColorBackgroundDisabled; + color: tokens.$clickCardHorizontalMutedColorTitleDisabled; + border: 1px solid tokens.$clickCardHorizontalMutedColorStrokeDisabled; + cursor: not-allowed; + + .cuiDescription { + color: tokens.$clickCardHorizontalMutedColorDescriptionDisabled; + font: tokens.$clickCardHorizontalTypographyDescriptionDisabled; + } + + &:hover, + &:active, + &:focus, + &:focus-within { + background-color: tokens.$clickCardHorizontalMutedColorBackgroundDisabled; + color: tokens.$clickCardHorizontalMutedColorTitleDisabled; + cursor: not-allowed; + + .cuiDescription { + color: tokens.$clickCardHorizontalMutedColorDescriptionDisabled; + font: tokens.$clickCardHorizontalTypographyDescriptionDisabled; + } + } + + @include variants.variant('cuiSelected') { + border: 1px solid tokens.$clickCardHorizontalMutedColorStrokeActive; + + &:active, + &:focus, + &:focus-within { + border: 1px solid tokens.$clickCardHorizontalMutedColorStrokeActive; + } + } + } + } +} + +.cuiHeader { + max-width: 100%; + gap: inherit; + display: flex; + width: 100%; + justify-content: space-between; + align-items: center; +} + +.cuiDescription { + display: flex; + flex-direction: column; + align-self: start; + gap: tokens.$clickCardHorizontalSpaceMdGap; + flex: 1; + width: 100%; +} + +.cuiCardIcon { + composes: cuiIconWrapper from '../Icon/Icon.module.scss'; + height: tokens.$clickCardHorizontalIconSizeAll; + width: tokens.$clickCardHorizontalIconSizeAll; +} + +.cuiContentWrapper { + display: flex; + flex-direction: row; + width: 100%; + + @include variants.variant('cuiSizeMd') { + gap: tokens.$clickCardHorizontalSpaceMdGap; + } + + @include variants.variant('cuiSizeSm') { + gap: tokens.$clickCardHorizontalSpaceSmGap; + } + + @include mixins.cuiMobile { + flex-direction: column; + } +} + +.cuiIconTextContentWrapper { + display: flex; + flex-direction: row; + align-items: center; + width: 100%; + + @include variants.variant('cuiSizeMd') { + gap: tokens.$clickCardHorizontalSpaceMdGap; + } + + @include variants.variant('cuiSizeSm') { + gap: tokens.$clickCardHorizontalSpaceSmGap; + } +} diff --git a/src/components/CardHorizontal/CardHorizontal.stories.module.scss b/src/components/CardHorizontal/CardHorizontal.stories.module.scss new file mode 100644 index 000000000..90c4d43db --- /dev/null +++ b/src/components/CardHorizontal/CardHorizontal.stories.module.scss @@ -0,0 +1,7 @@ +@use "cui-mixins" as mixins; +@use "cui-variants" as variants; + +.cuiGridCenter { + display: grid; + width: 60%; +} \ No newline at end of file diff --git a/src/components/CardHorizontal/CardHorizontal.stories.tsx b/src/components/CardHorizontal/CardHorizontal.stories.tsx index 9da52300a..efd72c3b8 100644 --- a/src/components/CardHorizontal/CardHorizontal.stories.tsx +++ b/src/components/CardHorizontal/CardHorizontal.stories.tsx @@ -1,54 +1,369 @@ -import { Meta, StoryObj } from "@storybook/react-vite"; -import { styled } from "styled-components"; - -import { ICON_NAMES } from "../Icon/types"; - import { CardHorizontal } from "./CardHorizontal"; +import { ICON_NAMES } from "@/components/Icon/types.ts"; +import styles from "./CardHorizontal.stories.module.scss"; -const GridCenter = styled.div` - display: grid; - width: 60%; -`; +const CardHorizontalExample = ({ ...props }) => { + return ( +
+ +
+ ); +}; -const meta: Meta = { - component: CardHorizontal, +export default { + component: CardHorizontalExample, title: "Cards/Horizontal Card", tags: ["cardHorizontal", "autodocs"], argTypes: { - icon: { type: { name: "enum", value: [...ICON_NAMES] } }, - badgeIcon: { type: { name: "enum", value: [...ICON_NAMES] } }, + icon: { control: "select", options: ICON_NAMES, description: "`IconName`" }, + size: { + control: "radio", + options: ["sm", "md"], + description: "`sm` `md`", + defaultValue: { summary: "md" }, + }, + badgeIcon: { control: "select", options: ICON_NAMES, description: "`IconName`" }, + badgeText: { + control: "text", + description: "Shows and hides the badge
`string`", + }, badgeState: { - type: { - name: "enum", - // FIXME should refer to the Badge constants - value: ["default", "success", "neutral", "danger", "disabled", "warning", "info"], - }, - }, - // FIXME should refer to a constant - badgeIconDir: { type: { name: "enum", value: ["start", "end"] } }, + control: "select", + options: ["default", "info", "success", "warning", "danger"], + description: "`BadgeState`", + }, + badgeIconDir: { + control: "radio", + options: ["start", "end"], + description: "`start` `end`", + }, + title: { control: "text", description: "`ReactNode`" }, + description: { control: "text", description: "`ReactNode`" }, + infoText: { + control: "text", + description: "Shows and hides the button
`string`", + }, + infoUrl: { control: "text", description: "`string`" }, + disabled: { + control: "boolean", + description: "`boolean`", + defaultValue: { summary: "false" }, + }, + isSelected: { + control: "boolean", + description: "`boolean`", + defaultValue: { summary: "false" }, + }, }, - decorators: Story => ( - - - - ), }; -export default meta; - -export const Playground: StoryObj = { +export const Playground = { args: { icon: "building", title: "Card title", description: "A description very interesting that presumably relates to the card.", disabled: false, isSelected: false, + size: "md", badgeText: "", - badgeIcon: undefined, + badgeIcon: null, badgeState: "default", - badgeIconDir: undefined, + badgeIconDir: "", infoText: "", infoUrl: "", - size: "md", + }, +}; + +export const Variations = { + render: () => ( +
+
+

Sizes

+
+ + +
+
+ +
+

Colors

+
+ + +
+
+ +
+

States

+
+ + + + +
+
+ +
+

Badge States

+
+ + + + + +
+
+ +
+

Badge with Icons

+
+ + +
+
+ +
+

With Button

+
+ + +
+
+ +
+

Custom Icons

+
+ + + +
+
+ +
+

Size & Color Combinations

+
+ + + + +
+
+
+ ), + parameters: { + controls: { disable: true }, + actions: { disable: true }, + pseudo: { + hover: [".cuiWrapper.cuiIsSelectable"], + focus: [".cuiWrapper.cuiIsSelectable"], + active: [".cuiWrapper.cuiIsSelectable"], + }, }, }; diff --git a/src/components/CardHorizontal/CardHorizontal.tsx b/src/components/CardHorizontal/CardHorizontal.tsx index 6dbd1a5b9..f92fad06b 100644 --- a/src/components/CardHorizontal/CardHorizontal.tsx +++ b/src/components/CardHorizontal/CardHorizontal.tsx @@ -1,5 +1,7 @@ -import { HTMLAttributes, MouseEventHandler, ReactNode } from "react"; -import { styled } from "styled-components"; +"use client"; + +import { ComponentPropsWithoutRef, MouseEventHandler, ReactNode } from "react"; +import clsx from "clsx"; import { Badge, BadgeState, @@ -9,219 +11,36 @@ import { Icon, IconName, } from "@/components"; +import { capitalize } from "@/utils/capitalize"; +import styles from "./CardHorizontal.module.scss"; type CardColor = "default" | "muted"; export type CardSize = "sm" | "md"; export interface CardHorizontalProps extends Omit< - HTMLAttributes, + ComponentPropsWithoutRef<"div">, "title" > { - /** The title text displayed in the card */ title?: ReactNode; - /** Icon to display in the card */ icon?: IconName; - /** Whether the card is disabled */ disabled?: boolean; - /** The description content of the card */ description?: ReactNode; - /** URL to navigate to when clicked */ infoUrl?: string; - /** Text for the action button (shows/hides the button) */ + /** Shows and hides the button */ infoText?: string; - /** Whether the card is in a selected state */ isSelected?: boolean; - /** Whether the card can be selected */ isSelectable?: boolean; - /** Additional content to display in the card */ children?: ReactNode; - /** Color variant of the card */ color?: CardColor; - /** Size variant of the card */ size?: CardSize; - /** Text for the badge (shows/hides the badge) */ + /** Shows and hides the badge */ badgeText?: string; - /** State/color variant of the badge */ badgeState?: BadgeState; - /** Icon to display in the badge */ badgeIcon?: IconName; - /** Direction of the badge icon */ badgeIconDir?: HorizontalDirection; - /** Callback when the card button is clicked */ onButtonClick?: MouseEventHandler; } -const Header = styled.div` - max-width: 100%; - gap: inherit; -`; - -const Description = styled.div` - display: flex; - flex-direction: column; - align-self: start; - gap: ${({ theme }) => theme.click.card.horizontal.space.md.gap}; - flex: 1; - width: 100%; -`; - -const Wrapper = styled.div<{ - $hasShadow?: boolean; - $disabled?: boolean; - $isSelected?: boolean; - $isSelectable?: boolean; - $color: CardColor; - $size?: CardSize; -}>` - display: inline-flex; - width: 100%; - max-width: 100%; - align-items: center; - justify-content: flex-start; - - ${({ theme, $color, $size, $isSelected, $isSelectable, $disabled }) => ` - background: ${theme.click.card.horizontal[$color].color.background.default}; - color: ${theme.click.card.horizontal[$color].color.title.default}; - border-radius: ${theme.click.card.horizontal.radii.all}; - border: 1px solid ${ - theme.click.card.horizontal[$color].color.stroke[ - $isSelectable ? ($isSelected ? "active" : "hover") : "default" - ] - }; - padding: ${ - $size === "md" - ? `${theme.click.card.horizontal.space.md.y} ${theme.click.card.horizontal.space.md.x}` - : `${theme.click.card.horizontal.space.sm.y} ${theme.click.card.horizontal.space.sm.x}` - }; - font: ${theme.click.card.horizontal.typography.title.default}; - ${Description} { - color: ${theme.click.card.horizontal[$color].color.description.default}; - font: ${theme.click.card.horizontal.typography.description.default}; - } - &:hover{ - background-color: ${ - theme.click.card.horizontal[$color].color.background[ - $isSelectable ? "hover" : "default" - ] - }; - color: ${ - theme.click.card.horizontal[$color].color.title[ - $isSelectable ? "hover" : "default" - ] - }; - border: 1px solid ${ - theme.click.card.horizontal[$color].color.stroke[ - $isSelectable ? ($isSelected ? "active" : "default") : "default" - ] - }; - cursor: ${$isSelectable ? "pointer" : "default"}; - font: ${theme.click.card.horizontal.typography.title.hover}; - ${Description} { - color: ${ - theme.click.card.horizontal[$color].color.description[ - $isSelectable ? "hover" : "default" - ] - }; - font: ${ - theme.click.card.horizontal.typography.description[ - $isSelectable ? "hover" : "default" - ] - }; - } - } - - &:active, &:focus, &:focus-within { - background-color: ${ - theme.click.card.horizontal[$color].color.background[ - $isSelectable ? "active" : "default" - ] - }; - color: ${ - theme.click.card.horizontal[$color].color.title[ - $isSelectable ? "active" : "default" - ] - }; - border: 1px solid ${ - theme.click.card.horizontal[$color].color.stroke[ - $isSelectable ? "active" : "default" - ] - }; - ${Description} { - color: ${ - theme.click.card.horizontal[$color].color.description[ - $isSelectable ? "active" : "default" - ] - }; - font: ${ - theme.click.card.horizontal.typography.description[ - $isSelectable ? "active" : "default" - ] - }; - } - } - ${ - $disabled - ? ` - pointer-events: none; - &, - &:hover, - &:active, &:focus, &:focus-within { - background-color: ${ - theme.click.card.horizontal[$color].color.background.disabled - }; - color: ${theme.click.card.horizontal[$color].color.title.disabled}; - border: 1px solid ${ - theme.click.card.horizontal[$color].color.stroke[ - $isSelected ? "active" : "disabled" - ] - }; - cursor: not-allowed; - ${Description} { - color: ${theme.click.card.horizontal[$color].color.description.disabled}; - font: ${theme.click.card.horizontal.typography.description.disabled}; - } - }, - &:active, &:focus, &:focus-within { - border: 1px solid ${theme.click.card.horizontal[$color].color.stroke.active}; - } - ` - : "" - } - `} -`; - -const CardIcon = styled(Icon)` - ${({ theme }) => ` - height: ${theme.click.card.horizontal.icon.size.all}; - width: ${theme.click.card.horizontal.icon.size.all}; - `} -`; - -const ContentWrapper = styled.div<{ $size: CardSize }>` - display: flex; - flex-direction: row; - width: 100%; - gap: ${({ theme, $size }) => - $size === "md" - ? theme.click.card.horizontal.space.md.gap - : theme.click.card.horizontal.space.sm.gap}; - - @media (max-width: ${({ theme }) => theme.breakpoint.sizes.md}) { - flex-direction: column; - } -`; - -const IconTextContentWrapper = styled.div<{ $size: CardSize }>` - display: flex; - flex-direction: row; - align-items: center; - width: 100%; - gap: ${({ theme, $size }) => - $size === "md" - ? theme.click.card.horizontal.space.md.gap - : theme.click.card.horizontal.space.sm.gap}; -`; - export const CardHorizontal = ({ title, icon, @@ -239,14 +58,10 @@ export const CardHorizontal = ({ badgeIcon, badgeIconDir, onButtonClick, + className, ...props }: CardHorizontalProps) => { const handleClick = (e: React.MouseEvent) => { - if (disabled) { - e.preventDefault(); - return; - } - if (typeof onButtonClick === "function") { onButtonClick(e); } @@ -254,24 +69,49 @@ export const CardHorizontal = ({ window.open(infoUrl, "_blank"); } }; + + const colorClass = `cuiColor${capitalize(color)}`; + const sizeClass = `cuiSize${capitalize(size)}`; + const selectedClass = isSelected ? "cuiSelected" : undefined; + const disabledClass = disabled ? "cuiDisabled" : undefined; + + const wrapperClasses = clsx( + styles.cuiWrapper, + styles[colorClass], + styles[sizeClass], + selectedClass && styles[selectedClass], + disabledClass && styles[disabledClass], + { + [styles.cuiIsSelectable]: isSelectable, + }, + className + ); + + const contentWrapperClasses = clsx(styles.cuiContentWrapper, styles[sizeClass]); + const iconTextContentWrapperClasses = clsx( + styles.cuiIconTextContentWrapper, + styles[sizeClass] + ); + const iconClasses = clsx(styles.cuiCardIcon); + return ( - - - +
+
{icon && ( - )} {title && ( -
+
)} -
+
)} - {description && {description}} - {children && {children}} + {description &&
{description}
} + {children &&
{children}
} - +
{infoText && ( )} -
-
+ + ); }; diff --git a/src/components/CardPrimary/CardPrimary.module.scss b/src/components/CardPrimary/CardPrimary.module.scss new file mode 100644 index 000000000..ee6c8577d --- /dev/null +++ b/src/components/CardPrimary/CardPrimary.module.scss @@ -0,0 +1,189 @@ +@use "cui-mixins" as mixins; +@use "cui-variants" as variants; + +// Icon styles with composes - overrides Icon's default size +.cuiIconSm { + composes: cuiIconWrapper from "../Icon/Icon.module.scss"; + width: tokens.$clickCardPrimarySizeIconSmAll; + height: tokens.$clickCardPrimarySizeIconSmAll; +} + +.cuiIconMd { + composes: cuiIconWrapper from "../Icon/Icon.module.scss"; + width: tokens.$clickCardPrimarySizeIconMdAll; + height: tokens.$clickCardPrimarySizeIconMdAll; +} + +.cuiWrapper { + @include mixins.cuiCardBase; + + background-color: tokens.$clickCardPrimaryColorBackgroundDefault; + border-radius: tokens.$clickCardPrimaryRadiiAll; + border: 1px solid tokens.$clickCardPrimaryColorStrokeDefault; + display: flex; + width: 100%; + max-width: 100%; + flex-direction: column; + box-shadow: none; + + &.cuiHasShadow { + box-shadow: var(--shadow-1); + } + + // Size variants - using :where() for low specificity + @include variants.variant("cuiSizeSm") { + padding: tokens.$clickCardPrimarySpaceSmX tokens.$clickCardPrimarySpaceSmY; + gap: tokens.$clickCardPrimarySpaceSmGap; + } + + @include variants.variant("cuiSizeMd") { + padding: tokens.$clickCardPrimarySpaceMdX tokens.$clickCardPrimarySpaceMdY; + gap: tokens.$clickCardPrimarySpaceMdGap; + } + + // Align variants - using :where() for low specificity + @include variants.variant("cuiAlignStart") { + text-align: left; + } + + @include variants.variant("cuiAlignCenter") { + text-align: center; + } + + @include variants.variant("cuiAlignEnd") { + text-align: right; + } + + &:hover, + &:focus { + background-color: tokens.$clickCardSecondaryColorBackgroundHover; + cursor: pointer; + + button { + background-color: tokens.$clickButtonBasicColorPrimaryBackgroundHover; + border-color: tokens.$clickButtonBasicColorPrimaryStrokeHover; + + &:active { + background-color: tokens.$clickButtonBasicColorPrimaryBackgroundActive; + border-color: tokens.$clickButtonBasicColorPrimaryStrokeActive; + } + } + } + + &:active { + border-color: tokens.$clickButtonBasicColorPrimaryStrokeActive; + } + + &.cuiIsSelected { + border-color: tokens.$clickButtonBasicColorPrimaryStrokeActive; + } + + &[aria-disabled="true"], + &[aria-disabled="true"]:hover, + &[aria-disabled="true"]:focus, + &[aria-disabled="true"]:active { + pointer-events: none; + background-color: tokens.$clickCardPrimaryColorBackgroundDisabled; + color: tokens.$clickCardPrimaryColorTitleDisabled; + border: 1px solid tokens.$clickCardPrimaryColorStrokeDisabled; + cursor: not-allowed; + + button { + background-color: tokens.$clickButtonBasicColorPrimaryBackgroundDisabled; + border-color: tokens.$clickButtonBasicColorPrimaryStrokeDisabled; + + &:active { + background-color: tokens.$clickButtonBasicColorPrimaryBackgroundDisabled; + border-color: tokens.$clickButtonBasicColorPrimaryStrokeDisabled; + } + } + } +} + +.cuiHeader { + display: flex; + flex-direction: column; + + // Align variants - using :where() for low specificity + @include variants.variant("cuiAlignStart") { + align-items: flex-start; + } + + @include variants.variant("cuiAlignCenter") { + align-items: center; + } + + @include variants.variant("cuiAlignEnd") { + align-items: flex-end; + } + + // Size variants - using :where() for low specificity + @include variants.variant("cuiSizeSm") { + gap: tokens.$clickCardPrimarySpaceSmGap; + } + + @include variants.variant("cuiSizeMd") { + gap: tokens.$clickCardPrimarySpaceMdGap; + } + + h3 { + color: tokens.$clickGlobalColorTextDefault; + } + + &.cuiDisabled h3 { + color: tokens.$clickGlobalColorTextMuted; + } + + // Icon wrapper sizing (applies to Icon component wrapper) + .cuiSizeSm { + height: tokens.$clickCardPrimarySizeIconSmAll; + width: tokens.$clickCardPrimarySizeIconSmAll; + } + + .cuiSizeMd { + height: tokens.$clickCardPrimarySizeIconMdAll; + width: tokens.$clickCardPrimarySizeIconMdAll; + } + + // Direct img sizing (for iconUrl prop) + img { + &.cuiSizeSm { + height: tokens.$clickCardPrimarySizeIconSmAll; + width: tokens.$clickCardPrimarySizeIconSmAll; + } + + &.cuiSizeMd { + height: tokens.$clickCardPrimarySizeIconMdAll; + width: tokens.$clickCardPrimarySizeIconMdAll; + } + } +} + +.cuiContent { + width: 100%; + display: flex; + flex-direction: column; + flex: 1; + + // Align variants - using :where() for low specificity + @include variants.variant("cuiAlignStart") { + align-self: flex-start; + } + + @include variants.variant("cuiAlignCenter") { + align-self: center; + } + + @include variants.variant("cuiAlignEnd") { + align-self: flex-end; + } + + // Size variants - using :where() for low specificity + @include variants.variant("cuiSizeSm") { + gap: tokens.$clickCardPrimarySpaceSmGap; + } + + @include variants.variant("cuiSizeMd") { + gap: tokens.$clickCardPrimarySpaceMdGap; + } +} diff --git a/src/components/CardPrimary/CardPrimary.stories.ts b/src/components/CardPrimary/CardPrimary.stories.ts deleted file mode 100644 index 4d85ed848..000000000 --- a/src/components/CardPrimary/CardPrimary.stories.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Meta, StoryObj } from "@storybook/react-vite"; -import { CardPrimary } from "./CardPrimary"; - -const meta: Meta = { - component: CardPrimary, - title: "Cards/Primary Card", - tags: ["cardPrimary", "autodocs"], -}; - -export default meta; - -type Story = StoryObj; - -export const Playground: Story = { - args: { - icon: "building", - title: "Card title", - description: "A description very interesting that presumably relates to the card.", - infoUrl: "https://clickhouse.com", - infoText: "Read More", - hasShadow: false, - disabled: false, - isSelected: true, - size: "md", - alignContent: "center", - topBadgeText: "Top badge", - }, -}; diff --git a/src/components/CardPrimary/CardPrimary.stories.tsx b/src/components/CardPrimary/CardPrimary.stories.tsx new file mode 100644 index 000000000..757a06195 --- /dev/null +++ b/src/components/CardPrimary/CardPrimary.stories.tsx @@ -0,0 +1,249 @@ +import { Meta, StoryObj } from "@storybook/react-vite"; +import { CardPrimary } from "./CardPrimary"; + +const meta: Meta = { + component: CardPrimary, + title: "Cards/Primary Card", + tags: ["cardPrimary", "autodocs"], +}; + +export default meta; + +type Story = StoryObj; + +export const Playground: Story = { + args: { + icon: "building", + title: "Card title", + description: "A description very interesting that presumably relates to the card.", + infoUrl: "https://clickhouse.com", + infoText: "Read More", + hasShadow: false, + disabled: false, + isSelected: true, + size: "md", + alignContent: "center", + topBadgeText: "Top badge", + }, +}; + +export const Variations: Story = { + render: () => ( +
+
+

Sizes

+
+ + +
+
+ +
+

Alignment

+
+ + + +
+
+ +
+

States

+
+ + + +
+
+ +
+

With Shadow

+
+ + +
+
+ +
+

With Top Badge

+
+ + +
+
+ +
+

Without Button

+
+ + +
+
+ +
+

Custom Icons

+
+ + + +
+
+
+ ), + parameters: { + controls: { disable: true }, + actions: { disable: true }, + pseudo: { + hover: [".cuiWrapper"], + focus: [".cuiWrapper"], + active: [".cuiWrapper"], + }, + }, +}; diff --git a/src/components/CardPrimary/CardPrimary.tsx b/src/components/CardPrimary/CardPrimary.tsx index e9014a94e..b70108222 100644 --- a/src/components/CardPrimary/CardPrimary.tsx +++ b/src/components/CardPrimary/CardPrimary.tsx @@ -1,14 +1,18 @@ -import { styled } from "styled-components"; +"use client"; + +import clsx from "clsx"; import { Button, Icon, Spacer, IconName } from "@/components"; import { Title } from "@/components/Typography/Title/Title"; import { Text, TextAlignment } from "@/components/Typography/Text/Text"; -import { HTMLAttributes, MouseEvent, MouseEventHandler, ReactNode } from "react"; +import { ComponentPropsWithoutRef, MouseEvent, MouseEventHandler, ReactNode } from "react"; import { WithTopBadgeProps, withTopBadge } from "@/components/CardPrimary/withTopBadge"; +import { capitalize } from "../../utils/capitalize"; +import styles from "./CardPrimary.module.scss"; export type CardPrimarySize = "sm" | "md"; type ContentAlignment = "start" | "center" | "end"; export interface CardPrimaryProps - extends HTMLAttributes, WithTopBadgeProps { + extends ComponentPropsWithoutRef<"div">, WithTopBadgeProps { /** The title text displayed in the card */ title?: string; /** Icon name to display in the card header */ @@ -37,109 +41,6 @@ export interface CardPrimaryProps onButtonClick?: MouseEventHandler; } -const Wrapper = styled.div<{ - $size?: CardPrimarySize; - $hasShadow?: boolean; - $isSelected?: boolean; - $alignContent?: ContentAlignment; -}>` - background-color: ${({ theme }) => theme.click.card.primary.color.background.default}; - border-radius: ${({ theme }) => theme.click.card.primary.radii.all}; - border: ${({ theme }) => `1px solid ${theme.click.card.primary.color.stroke.default}`}; - display: flex; - width: 100%; - max-width: 100%; - text-align: ${({ $alignContent }) => - $alignContent === "start" ? "left" : $alignContent === "end" ? "right" : "center"}; - flex-direction: column; - padding: ${({ $size = "md", theme }) => - `${theme.click.card.primary.space[$size].x} ${theme.click.card.primary.space[$size].y}`}; - gap: ${({ $size = "md", theme }) => theme.click.card.primary.space[$size].gap}; - box-shadow: ${({ $hasShadow, theme }) => ($hasShadow ? theme.shadow[1] : "none")}; - - &:hover, - &:focus { - background-color: ${({ theme }) => theme.click.card.secondary.color.background.hover}; - cursor: pointer; - button { - background-color: ${({ theme }) => - theme.click.button.basic.color.primary.background.hover}; - border-color: ${({ theme }) => theme.click.button.basic.color.primary.stroke.hover}; - &:active { - background-color: ${({ theme }) => - theme.click.button.basic.color.primary.background.active}; - border-color: ${({ theme }) => - theme.click.button.basic.color.primary.stroke.active}; - } - } - } - - &:active { - border-color: ${({ theme }) => theme.click.button.basic.color.primary.stroke.active}; - } - - &[aria-disabled="true"], - &[aria-disabled="true"]:hover, - &[aria-disabled="true"]:focus, - &[aria-disabled="true"]:active { - pointer-events: none; - ${({ theme }) => ` - background-color: ${theme.click.card.primary.color.background.disabled}; - color: ${theme.click.card.primary.color.title.disabled}; - border: 1px solid ${theme.click.card.primary.color.stroke.disabled}; - cursor: not-allowed; - - button { - background-color: ${theme.click.button.basic.color.primary.background.disabled}; - border-color: ${theme.click.button.basic.color.primary.stroke.disabled}; - &:active { - background-color: ${theme.click.button.basic.color.primary.background.disabled}; - border-color: ${theme.click.button.basic.color.primary.stroke.disabled}; - } - }`} - } - - ${({ $isSelected, theme }) => - $isSelected - ? `border-color: ${theme.click.button.basic.color.primary.stroke.active};` - : ""} -`; - -const Header = styled.div<{ - $size?: "sm" | "md"; - $disabled?: boolean; - $alignContent?: ContentAlignment; -}>` - display: flex; - flex-direction: column; - align-items: ${({ $alignContent = "center" }) => - ["start", "end"].includes($alignContent) ? `flex-${$alignContent}` : $alignContent}; - gap: ${({ $size = "md", theme }) => theme.click.card.primary.space[$size].gap}; - - h3 { - color: ${({ $disabled, theme }) => - $disabled == true - ? theme.click.global.color.text.muted - : theme.click.global.color.text.default}; - } - - svg, - img { - height: ${({ $size = "md", theme }) => theme.click.card.primary.size.icon[$size].all}; - width: ${({ $size = "md", theme }) => theme.click.card.primary.size.icon[$size].all}; - } -`; - -const Content = styled.div<{ $size?: "sm" | "md"; $alignContent?: ContentAlignment }>` - width: 100%; - display: flex; - flex-direction: column; - align-self: ${({ $alignContent = "center" }) => - ["start", "end"].includes($alignContent) ? `flex-${$alignContent}` : $alignContent}; - gap: ${({ $size = "md", theme }) => theme.click.card.primary.space[$size].gap}; - flex: 1; -`; - const convertCardAlignToTextAlign = (align: ContentAlignment): TextAlignment => { if (align === "center") { return "center"; @@ -148,7 +49,7 @@ const convertCardAlignToTextAlign = (align: ContentAlignment): TextAlignment => }; const Card = ({ - alignContent, + alignContent = "center", title, icon, iconUrl, @@ -156,11 +57,12 @@ const Card = ({ description, infoUrl, infoText, - size, + size = "md", disabled = false, onButtonClick, isSelected, children, + className, ...props }: CardPrimaryProps) => { const handleClick = (e: MouseEvent) => { @@ -172,56 +74,81 @@ const Card = ({ } }; + const sizeClass = `cuiSize${capitalize(size)}`; + const alignClass = `cuiAlign${capitalize(alignContent)}`; + const iconSizeClass = `cuiIcon${capitalize(size)}`; + + const wrapperClasses = clsx( + styles.cuiWrapper, + styles[sizeClass], + styles[alignClass], + { + [styles.cuiHasShadow]: hasShadow, + [styles.cuiIsSelected]: isSelected, + }, + className + ); + + const headerClasses = clsx(styles.cuiHeader, styles[sizeClass], styles[alignClass], { + [styles.cuiDisabled]: disabled, + }); + + const contentClasses = clsx(styles.cuiContent, styles[sizeClass], styles[alignClass]); + + const iconClasses = styles[iconSizeClass]; + const Component = !!infoUrl || typeof onButtonClick === "function" ? Button : "div"; return ( - {(icon || title) && ( -
{iconUrl ? ( card icon ) : ( icon && ( ) )} {title && {title}} -
+ )} {(description || children) && ( - {description && ( {description} )} {children} - + )} {size == "sm" && } @@ -234,7 +161,7 @@ const Card = ({ {infoText} )} -
+ ); }; diff --git a/src/components/CardPrimary/CardPrimaryTopBadge.module.scss b/src/components/CardPrimary/CardPrimaryTopBadge.module.scss new file mode 100644 index 000000000..fe0d8307f --- /dev/null +++ b/src/components/CardPrimary/CardPrimaryTopBadge.module.scss @@ -0,0 +1,12 @@ +@use "cui-mixins" as mixins; + +.cuiTopBadgeWrapper { + position: relative; +} + +.cuiCardPrimaryTopBadge { + position: absolute; + top: 0; + left: 50%; + transform: translate(-50%, -50%); +} diff --git a/src/components/CardPrimary/CardPrimaryTopBadge.tsx b/src/components/CardPrimary/CardPrimaryTopBadge.tsx index da2835f7b..bcfaed224 100644 --- a/src/components/CardPrimary/CardPrimaryTopBadge.tsx +++ b/src/components/CardPrimary/CardPrimaryTopBadge.tsx @@ -1,21 +1,39 @@ -import { Badge } from "@/components/Badge/Badge"; -import { Container } from "@/components/Container/Container"; -import { styled } from "styled-components"; +import clsx from "clsx"; +import { Badge, CommonBadgeProps } from "@/components/Badge/Badge"; +import { Container, ContainerProps } from "@/components/Container/Container"; +import styles from "./CardPrimaryTopBadge.module.scss"; -export const TopBadgeWrapper = styled(Container)` - position: relative; -`; +interface TopBadgeWrapperProps extends ContainerProps { + className?: string; +} -export const CardPrimaryTopBadge = styled(Badge)<{ $isSelected?: boolean }>` - position: absolute; - top: 0; - left: 50%; - transform: translate(-50%, -50%); - ${({ $isSelected, theme }) => - $isSelected - ? `border-color: ${theme.click.button.basic.color.primary.stroke.active};` - : ""} - div:active + & { - border-color: ${({ theme }) => theme.click.button.basic.color.primary.stroke.active}; - } -`; +export const TopBadgeWrapper = ({ className, ...props }: TopBadgeWrapperProps) => ( + +); + +interface CardPrimaryTopBadgeProps extends CommonBadgeProps { + $isSelected?: boolean; + className?: string; + dismissible?: never; + onClose?: never; +} + +export const CardPrimaryTopBadge = ({ + $isSelected, + className, + ...props +}: CardPrimaryTopBadgeProps) => { + const badgeClasses = clsx(styles.cuiCardPrimaryTopBadge, className); + + return ( + + ); +}; diff --git a/src/components/CardPrimary/_mixins.scss b/src/components/CardPrimary/_mixins.scss new file mode 100644 index 000000000..4ac5ef9bc --- /dev/null +++ b/src/components/CardPrimary/_mixins.scss @@ -0,0 +1,19 @@ +@use "../../styles/tokens-light-dark" as tokens; + +// Card mixins for Click UI Card components + +// Card base mixin - common card styling +@mixin cuiCardBase { + display: flex; + border-radius: var(--click-card-radii-all); + background: var(--click-card-color-background-default); + border: var(--click-card-stroke-default) solid var(--click-card-color-stroke-default); + transition: var(--click-card-transitions-all); +} + +// Card state mixin - uses dynamic CSS custom properties for color variants +@mixin cuiCardState($color, $state) { + background: var(--click-card-#{$color}-color-background-#{$state}); + color: var(--click-card-#{$color}-color-title-#{$state}); + border-color: var(--click-card-#{$color}-color-stroke-#{$state}); +} diff --git a/src/components/CardPromotion/CardPromotion.module.scss b/src/components/CardPromotion/CardPromotion.module.scss new file mode 100644 index 000000000..4f0a731b3 --- /dev/null +++ b/src/components/CardPromotion/CardPromotion.module.scss @@ -0,0 +1,62 @@ +@use "cui-mixins" as mixins; + +// Icon styles with composes - overrides Icon's default size +.cuiCardIcon { + composes: cuiIconWrapper from '../Icon/Icon.module.scss'; + height: tokens.$clickCardPromotionIconSizeAll; + width: tokens.$clickCardPromotionIconSizeAll; + color: tokens.$clickCardPromotionColorIconDefault; +} + +.cuiBackground { + background-image: tokens.$clickCardPromotionColorStrokeDefault; + padding: 1px; + border-radius: tokens.$clickCardPromotionRadiiAll; + box-shadow: tokens.$clickCardShadow; + display: flex; + + &:focus { + background: tokens.$clickCardPromotionColorStrokeFocus; + } +} + +.cuiWrapper { + display: flex; + width: 100%; + align-items: center; + justify-content: flex-start; + cursor: pointer; + background: tokens.$clickCardPromotionColorBackgroundDefault; + color: tokens.$clickCardPromotionColorTextDefault; + border-radius: tokens.$clickCardPromotionRadiiAll; + padding: tokens.$clickCardPromotionSpaceY tokens.$clickCardPromotionSpaceX; + gap: tokens.$clickCardPromotionSpaceGap; + transition: 0.2s ease-in-out all; + + &:hover { + background: tokens.$clickCardPromotionColorBackgroundHover; + color: tokens.$clickCardPromotionColorTextHover; + } + + &:active, + &:focus { + background: tokens.$clickCardPromotionColorBackgroundActive; + color: tokens.$clickCardPromotionColorTextActive; + } +} + +.cuiCardIcon { + height: tokens.$clickCardPromotionIconSizeAll; + width: tokens.$clickCardPromotionIconSizeAll; + color: tokens.$clickCardPromotionColorIconDefault; +} + +.cuiDismissWrapper { + display: flex; + align-items: center; + margin-left: auto; + border: none; + background-color: transparent; + color: inherit; + cursor: pointer; +} diff --git a/src/components/CardPromotion/CardPromotion.stories.ts b/src/components/CardPromotion/CardPromotion.stories.ts deleted file mode 100644 index f3b3ecded..000000000 --- a/src/components/CardPromotion/CardPromotion.stories.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Meta, StoryObj } from "@storybook/react-vite"; -import { CardPromotion } from "./CardPromotion"; - -const meta: Meta = { - component: CardPromotion, - title: "Cards/Promotion Card", - tags: ["cardPromotion", "autodocs"], -}; - -export default meta; - -type Story = StoryObj; - -export const Playground: Story = { - args: { - icon: "star", - label: "Join us at AWS re:Invent in Las Vegas from Nov 27 - Dec 1", - dismissible: false, - }, -}; diff --git a/src/components/CardPromotion/CardPromotion.stories.tsx b/src/components/CardPromotion/CardPromotion.stories.tsx new file mode 100644 index 000000000..7a04000b1 --- /dev/null +++ b/src/components/CardPromotion/CardPromotion.stories.tsx @@ -0,0 +1,167 @@ +import { Meta, StoryObj } from "@storybook/react-vite"; +import { CardPromotion } from "./CardPromotion"; + +const meta: Meta = { + component: CardPromotion, + title: "Cards/Promotion Card", + tags: ["cardPromotion", "autodocs"], +}; + +export default meta; + +type Story = StoryObj; + +export const Playground: Story = { + args: { + icon: "star", + label: "Join us at AWS re:Invent in Las Vegas from Nov 27 - Dec 1", + dismissible: false, + }, +}; + +export const Variations: Story = { + render: () => ( +
+
+

Default State

+
+ +
+
+ +
+

Dismissible

+
+ +
+
+ +
+

Different Icons

+
+ + + + + +
+
+ +
+

Various Message Lengths

+
+ + + +
+
+ +
+

Different Icons with Dismissible

+
+ + + +
+
+
+ ), + parameters: { + controls: { disable: true }, + actions: { disable: true }, + pseudo: { + hover: [".cuiWrapper"], + active: [".cuiWrapper"], + focus: [".cuiBackground"], + }, + }, +}; diff --git a/src/components/CardPromotion/CardPromotion.tsx b/src/components/CardPromotion/CardPromotion.tsx index 781e2fdf8..ddd5c6b0e 100644 --- a/src/components/CardPromotion/CardPromotion.tsx +++ b/src/components/CardPromotion/CardPromotion.tsx @@ -1,8 +1,11 @@ -import { HTMLAttributes, useState } from "react"; -import { styled } from "styled-components"; +"use client"; + +import { ComponentPropsWithoutRef, useState } from "react"; +import clsx from "clsx"; import { Icon, IconName, Text } from "@/components"; +import styles from "./CardPromotion.module.scss"; -export interface CardPromotionProps extends HTMLAttributes { +export interface CardPromotionProps extends ComponentPropsWithoutRef<"div"> { /** The text label displayed in the promotion card */ label: string; /** The icon to display */ @@ -10,89 +13,33 @@ export interface CardPromotionProps extends HTMLAttributes { /** Whether the card can be dismissed/closed */ dismissible?: boolean; } -const Background = styled.div` - ${({ theme }) => ` - background-image: ${theme.click.card.promotion.color.stroke.default}; - padding: 1px; - border-radius: ${theme.click.card.promotion.radii.all}; - box-shadow: ${theme.click.card.shadow}; - display: flex; - - &:focus { - background: ${theme.click.card.promotion.color.stroke.focus}; - } - `} -`; -const Wrapper = styled.div<{ - $dismissible?: boolean; -}>` - display: flex; - width: 100%; - align-items: center; - justify-content: flex-start; - cursor: pointer; - - ${({ theme }) => ` - background: ${theme.click.card.promotion.color.background.default}; - color: ${theme.click.card.promotion.color.text.default}; - border-radius: ${theme.click.card.promotion.radii.all}; - padding: ${theme.click.card.promotion.space.y} ${theme.click.card.promotion.space.x}; - gap: ${theme.click.card.promotion.space.gap}; - transition: .2s ease-in-out all; - - &:hover { - background: ${theme.click.card.promotion.color.background.hover}; - color: ${theme.click.card.promotion.color.text.hover}; - } - - &:active, &:focus { - background: ${theme.click.card.promotion.color.background.active}; - color: ${theme.click.card.promotion.color.text.active}; - } - `} -`; - -const CardIcon = styled(Icon)` - ${({ theme }) => ` - height: ${theme.click.card.promotion.icon.size.all}; - width: ${theme.click.card.promotion.icon.size.all}; - color: ${theme.click.card.promotion.color.icon.default}; - `} -`; - -const DismissWrapper = styled.button` - display: flex; - align-items: center; - margin-left: auto; - border: none; - background-color: transparent; - color: inherit; - cursor: pointer; -`; export const CardPromotion = ({ label, icon, dismissible = false, + className, ...props }: CardPromotionProps) => { const [isVisible, setIsVisible] = useState(true); return isVisible ? ( - - +
- {label} {dismissible && ( - setIsVisible(false)} > @@ -100,9 +47,9 @@ export const CardPromotion = ({ name="cross" aria-label="close" /> - + )} - - +
+ ) : null; }; diff --git a/src/components/CardSecondary/CardSecondary.module.scss b/src/components/CardSecondary/CardSecondary.module.scss new file mode 100644 index 000000000..5ea25fdd7 --- /dev/null +++ b/src/components/CardSecondary/CardSecondary.module.scss @@ -0,0 +1,112 @@ +@use "cui-mixins" as mixins; +@use "cui-variants" as variants; + +.cuiWrapper { + background-color: tokens.$clickCardSecondaryColorBackgroundDefault; + border-radius: tokens.$clickCardSecondaryRadiiAll; + border: 1px solid tokens.$clickCardSecondaryColorStrokeDefault; + max-width: 420px; + min-width: 320px; + display: flex; + flex-direction: column; + padding: tokens.$clickCardSecondarySpaceAll; + gap: tokens.$clickCardSecondarySpaceGap; + + @include variants.variant('cuiHasShadow') { + box-shadow: var(--shadow-1); + } + + &:hover, + &:focus { + background-color: tokens.$clickCardSecondaryColorBackgroundHover; + cursor: pointer; + + .cuiLinkText, + .cuiLinkIcon { + color: tokens.$clickCardSecondaryColorLinkHover; + } + } + + @include variants.variant('cuiDisabled') { + pointer-events: none; + background-color: tokens.$clickCardSecondaryColorBackgroundDisabled; + color: tokens.$clickCardSecondaryColorTitleDisabled; + border: 1px solid tokens.$clickCardSecondaryColorStrokeDisabled; + cursor: not-allowed; + + .cuiLinkText, + .cuiLinkIcon { + color: tokens.$clickCardSecondaryColorLinkDisabled; + } + + &:hover, + &:focus, + &:active { + pointer-events: none; + background-color: tokens.$clickCardSecondaryColorBackgroundDisabled; + color: tokens.$clickCardSecondaryColorTitleDisabled; + border: 1px solid tokens.$clickCardSecondaryColorStrokeDisabled; + cursor: not-allowed; + + .cuiLinkText, + .cuiLinkIcon { + color: tokens.$clickCardSecondaryColorLinkDisabled; + } + } + } +} + +.cuiHeader { + display: flex; + justify-content: space-between; + align-items: center; +} + +.cuiHeaderLeft { + display: flex; + align-items: center; + gap: tokens.$clickCardSecondarySpaceGap; + + h3, + svg { + color: tokens.$clickGlobalColorTextDefault; + } + + @include variants.variant('cuiDisabled') { + h3, + svg { + color: tokens.$clickGlobalColorTextMuted; + } + } +} + +.cuiContent { + display: flex; + flex-direction: column; + flex: 1; +} + +.cuiCustomIcon { + composes: cuiIconWrapper from '../Icon/Icon.module.scss'; + height: tokens.$clickImageLgSizeHeight; + width: tokens.$clickImageLgSizeWidth; +} + +.cuiInfoLink { + display: flex; + align-items: center; + color: tokens.$clickCardSecondaryColorLinkDefault; + gap: tokens.$clickCardSecondarySpaceLinkGap; + text-decoration: none; +} + +.cuiLinkText { + color: tokens.$clickCardSecondaryColorLinkDefault; +} + +.cuiLinkIcon { + composes: cuiIconWrapper from '../Icon/Icon.module.scss'; + color: tokens.$clickCardSecondaryColorLinkDefault; + height: tokens.$clickImageMdSizeHeight; + width: tokens.$clickImageMdSizeWidth; +} diff --git a/src/components/CardSecondary/CardSecondary.stories.ts b/src/components/CardSecondary/CardSecondary.stories.ts deleted file mode 100644 index 7fbda62b0..000000000 --- a/src/components/CardSecondary/CardSecondary.stories.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Meta, StoryObj } from "@storybook/react-vite"; -import { CardSecondary } from "./CardSecondary"; - -const meta: Meta = { - component: CardSecondary, - title: "Cards/Secondary Card", - tags: ["cardSecondary", "autodocs"], -}; - -export default meta; - -type Story = StoryObj; - -export const Playground: Story = { - args: { - title: "Card title", - icon: "building", - description: "A description very interesting that presumably relates to the card", - infoUrl: "https://clickhouse.com", - infoText: "Read More", - hasShadow: false, - disabled: false, - badgeText: "experiment", - badgeState: "success", - }, -}; diff --git a/src/components/CardSecondary/CardSecondary.stories.tsx b/src/components/CardSecondary/CardSecondary.stories.tsx new file mode 100644 index 000000000..575c72ba5 --- /dev/null +++ b/src/components/CardSecondary/CardSecondary.stories.tsx @@ -0,0 +1,275 @@ +import { Meta, StoryObj } from "@storybook/react-vite"; +import { CardSecondary } from "./CardSecondary"; + +const meta: Meta = { + component: CardSecondary, + title: "Cards/Secondary Card", + tags: ["cardSecondary", "autodocs"], +}; + +export default meta; + +type Story = StoryObj; + +export const Playground: Story = { + args: { + title: "Card title", + icon: "building", + description: "A description very interesting that presumably relates to the card", + infoUrl: "https://clickhouse.com", + infoText: "Read More", + hasShadow: false, + disabled: false, + badgeText: "experiment", + badgeState: "success", + }, +}; + +export const Variations: Story = { + render: () => ( +
+
+

Badge States

+
+ + + +
+
+ + + +
+
+ +
+

States

+
+ + +
+
+ +
+

With Shadow

+
+ + +
+
+ +
+

Without Badge

+
+ + +
+
+ +
+

Custom Icons

+
+ + + +
+
+ +
+

Without Info Link

+
+ + +
+
+
+ ), + parameters: { + controls: { disable: true }, + actions: { disable: true }, + pseudo: { + hover: [".cuiWrapper"], + focus: [".cuiWrapper"], + }, + }, +}; diff --git a/src/components/CardSecondary/CardSecondary.tsx b/src/components/CardSecondary/CardSecondary.tsx index 75a87a212..711049273 100644 --- a/src/components/CardSecondary/CardSecondary.tsx +++ b/src/components/CardSecondary/CardSecondary.tsx @@ -1,9 +1,12 @@ -import { styled } from "styled-components"; +"use client"; + import { Badge, Icon, IconName } from "@/components"; import { Title } from "@/components/Typography/Title/Title"; import { Text } from "@/components/Typography/Text/Text"; import { IconSize } from "@/components/Icon/types"; -import { HTMLAttributes, ReactNode } from "react"; +import { ComponentPropsWithoutRef, ReactNode } from "react"; +import clsx from "clsx"; +import styles from "./CardSecondary.module.scss"; export type BadgeState = | "default" @@ -14,7 +17,7 @@ export type BadgeState = | "warning" | "info"; -export interface CardSecondaryProps extends HTMLAttributes { +export interface CardSecondaryProps extends ComponentPropsWithoutRef<"div"> { /** The title text displayed in the card header */ title: string; /** Icon name to display in the header */ @@ -41,97 +44,6 @@ export interface CardSecondaryProps extends HTMLAttributes { infoIconSize?: IconSize; } -const Header = styled.div` - display: flex; - justify-content: space-between; - align-items: center; -`; - -const HeaderLeft = styled.div<{ $disabled?: boolean }>` - display: flex; - align-items: center; - gap: ${({ theme }) => theme.click.card.secondary.space.gap}; - - h3, - svg { - color: ${({ $disabled, theme }) => - $disabled == true - ? theme.click.global.color.text.muted - : theme.click.global.color.text.default}; - } -`; - -const Content = styled.div` - display: flex; - flex-direction: column; - flex: 1; -`; - -const CustomIcon = styled.img` - height: ${({ theme }) => theme.click.image.lg.size.height}; - width: ${({ theme }) => theme.click.image.lg.size.width}; -`; - -const InfoLink = styled.a` - display: flex; - align-items: center; - color: ${({ theme }) => theme.click.card.secondary.color.link.default}; - gap: ${({ theme }) => theme.click.card.secondary.space.link.gap}; - text-decoration: none; -`; -const LinkIconContainer = styled(Icon)` - color: ${({ theme }) => theme.click.card.secondary.color.link.default}; - height: ${({ theme }) => theme.click.image.md.size.height}; - width: ${({ theme }) => theme.click.image.md.size.width}; -`; - -const LinkText = styled(Text)``; -const LinkIcon = styled(LinkIconContainer)``; - -const Wrapper = styled.div<{ - $hasShadow?: boolean; -}>` - background-color: ${({ theme }) => theme.click.card.secondary.color.background.default}; - border-radius: ${({ theme }) => theme.click.card.secondary.radii.all}; - border: ${({ theme }) => - `1px solid ${theme.click.card.secondary.color.stroke.default}`}; - max-width: 420px; - min-width: 320px; - display: flex; - flex-direction: column; - padding: ${({ theme }) => theme.click.card.secondary.space.all}; - gap: ${({ theme }) => theme.click.card.secondary.space.gap}; - box-shadow: ${({ $hasShadow, theme }) => ($hasShadow ? theme.shadow[1] : "none")}; - - &:hover, - :focus { - background-color: ${({ theme }) => theme.click.card.secondary.color.background.hover}; - cursor: pointer; - ${LinkText}, - ${LinkIcon} { - color: ${({ theme }) => theme.click.card.secondary.color.link.hover}; - } - } - - &[aria-disabled="true"], - &[aria-disabled="true"]:hover, - &[aria-disabled="true"]:focus, - &[aria-disabled="true"]:active { - pointer-events: none; - ${({ theme }) => ` - background-color: ${theme.click.card.secondary.color.background.disabled}; - color: ${theme.click.card.secondary.color.title.disabled}; - border: 1px solid ${theme.click.card.secondary.color.stroke.disabled}; - cursor: not-allowed; - - ${LinkText}, - ${LinkIcon} { - color: ${theme.click.card.secondary.color.link.disabled}; - } - `} - } -`; - export const CardSecondary = ({ title, icon, @@ -145,19 +57,35 @@ export const CardSecondary = ({ infoText, infoIcon = "chevron-right", infoIconSize = "md", + className, ...props }: CardSecondaryProps) => { + const InfoLinkComponent = disabled || !infoUrl || infoUrl.length === 0 ? "div" : "a"; + return ( - -
- +
+
{iconUrl ? ( - {title} - +
{badgeText && ( )} -
+ - +
{description} - +
{(infoUrl || infoText) && ( - - {infoText} - {infoText} + - + )} -
+ ); }; diff --git a/src/components/Checkbox/Checkbox.module.scss b/src/components/Checkbox/Checkbox.module.scss new file mode 100644 index 000000000..81a715384 --- /dev/null +++ b/src/components/Checkbox/Checkbox.module.scss @@ -0,0 +1,192 @@ +@use "cui-mixins" as mixins; +@use "cui-variants" as variants; + +.cuiWrapper { + @include mixins.cuiFormRoot($align: center, $maxWidth: fit-content); + + // Orientation and direction combinations using :where() for low specificity + @include variants.variant('cuiOrientationHorizontal') { + @include variants.variant('cuiDirStart') { + flex-direction: row-reverse; + } + @include variants.variant('cuiDirEnd') { + flex-direction: row; + } + } + + @include variants.variant('cuiOrientationVertical') { + @include variants.variant('cuiDirStart') { + flex-direction: column-reverse; + } + @include variants.variant('cuiDirEnd') { + flex-direction: column; + } + } +} + +.cuiCheckInput { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + border-radius: tokens.$clickCheckboxRadiiAll; + width: tokens.$clickCheckboxSizeAll; + height: tokens.$clickCheckboxSizeAll; + cursor: pointer; + border: 1px solid; + + // Variant styles using :where() for low specificity + @include variants.variant('cuiVariantDefault') { + background: tokens.$clickCheckboxColorVariationsDefaultBackgroundDefault; + border-color: tokens.$clickCheckboxColorVariationsDefaultStrokeDefault; + + @include variants.variant('cuiCheckedTrue') { + border-color: tokens.$clickCheckboxColorVariationsDefaultStrokeActive; + background: tokens.$clickCheckboxColorVariationsDefaultBackgroundActive; + } + + @include variants.variant('cuiCheckedIndeterminate') { + border-color: tokens.$clickCheckboxColorVariationsDefaultStrokeActive; + background: tokens.$clickCheckboxColorVariationsDefaultBackgroundActive; + } + } + + @include variants.variant('cuiVariantVar1') { + background: tokens.$clickCheckboxColorVariationsVar1BackgroundDefault; + border-color: tokens.$clickCheckboxColorVariationsVar1StrokeDefault; + + &:hover { + background: tokens.$clickCheckboxColorVariationsVar1BackgroundHover; + } + + @include variants.variant('cuiCheckedTrue') { + border-color: tokens.$clickCheckboxColorVariationsVar1StrokeActive; + background: tokens.$clickCheckboxColorVariationsVar1BackgroundActive; + } + + @include variants.variant('cuiCheckedIndeterminate') { + border-color: tokens.$clickCheckboxColorVariationsVar1StrokeActive; + background: tokens.$clickCheckboxColorVariationsVar1BackgroundActive; + } + } + + @include variants.variant('cuiVariantVar2') { + background: tokens.$clickCheckboxColorVariationsVar2BackgroundDefault; + border-color: tokens.$clickCheckboxColorVariationsVar2StrokeDefault; + + &:hover { + background: tokens.$clickCheckboxColorVariationsVar2BackgroundHover; + } + + @include variants.variant('cuiCheckedTrue') { + border-color: tokens.$clickCheckboxColorVariationsVar2StrokeActive; + background: tokens.$clickCheckboxColorVariationsVar2BackgroundActive; + } + + @include variants.variant('cuiCheckedIndeterminate') { + border-color: tokens.$clickCheckboxColorVariationsVar2StrokeActive; + background: tokens.$clickCheckboxColorVariationsVar2BackgroundActive; + } + } + + @include variants.variant('cuiVariantVar3') { + background: tokens.$clickCheckboxColorVariationsVar3BackgroundDefault; + border-color: tokens.$clickCheckboxColorVariationsVar3StrokeDefault; + + &:hover { + background: tokens.$clickCheckboxColorVariationsVar3BackgroundHover; + } + + @include variants.variant('cuiCheckedTrue') { + border-color: tokens.$clickCheckboxColorVariationsVar3StrokeActive; + background: tokens.$clickCheckboxColorVariationsVar3BackgroundActive; + } + + @include variants.variant('cuiCheckedIndeterminate') { + border-color: tokens.$clickCheckboxColorVariationsVar3StrokeActive; + background: tokens.$clickCheckboxColorVariationsVar3BackgroundActive; + } + } + + @include variants.variant('cuiVariantVar4') { + background: tokens.$clickCheckboxColorVariationsVar4BackgroundDefault; + border-color: tokens.$clickCheckboxColorVariationsVar4StrokeDefault; + + &:hover { + background: tokens.$clickCheckboxColorVariationsVar4BackgroundHover; + } + + @include variants.variant('cuiCheckedTrue') { + border-color: tokens.$clickCheckboxColorVariationsVar4StrokeActive; + background: tokens.$clickCheckboxColorVariationsVar4BackgroundActive; + } + + @include variants.variant('cuiCheckedIndeterminate') { + border-color: tokens.$clickCheckboxColorVariationsVar4StrokeActive; + background: tokens.$clickCheckboxColorVariationsVar4BackgroundActive; + } + } + + @include variants.variant('cuiVariantVar5') { + background: tokens.$clickCheckboxColorVariationsVar5BackgroundDefault; + border-color: tokens.$clickCheckboxColorVariationsVar5StrokeDefault; + + &:hover { + background: tokens.$clickCheckboxColorVariationsVar5BackgroundHover; + } + + @include variants.variant('cuiCheckedTrue') { + border-color: tokens.$clickCheckboxColorVariationsVar5StrokeActive; + background: tokens.$clickCheckboxColorVariationsVar5BackgroundActive; + } + + @include variants.variant('cuiCheckedIndeterminate') { + border-color: tokens.$clickCheckboxColorVariationsVar5StrokeActive; + background: tokens.$clickCheckboxColorVariationsVar5BackgroundActive; + } + } + + @include variants.variant('cuiVariantVar6') { + background: tokens.$clickCheckboxColorVariationsVar6BackgroundDefault; + border-color: tokens.$clickCheckboxColorVariationsVar6StrokeDefault; + + &:hover { + background: tokens.$clickCheckboxColorVariationsVar6BackgroundHover; + } + + @include variants.variant('cuiCheckedTrue') { + border-color: tokens.$clickCheckboxColorVariationsVar6StrokeActive; + background: tokens.$clickCheckboxColorVariationsVar6BackgroundActive; + } + + @include variants.variant('cuiCheckedIndeterminate') { + border-color: tokens.$clickCheckboxColorVariationsVar6StrokeActive; + background: tokens.$clickCheckboxColorVariationsVar6BackgroundActive; + } + } + + // Disabled state using :where() for low specificity + @include variants.variant('cuiDisabled') { + background: tokens.$clickCheckboxColorBackgroundDisabled; + border-color: tokens.$clickCheckboxColorStrokeDisabled; + cursor: not-allowed; + + @include variants.variant('cuiCheckedTrue') { + background: tokens.$clickCheckboxColorBackgroundDisabled; + border-color: tokens.$clickCheckboxColorStrokeDisabled; + } + + @include variants.variant('cuiCheckedIndeterminate') { + background: tokens.$clickCheckboxColorBackgroundDisabled; + border-color: tokens.$clickCheckboxColorStrokeDisabled; + } + } +} + +.cuiCheckIconWrapper { + color: tokens.$clickCheckboxColorCheckActive; + + @include variants.variant('cuiDisabled') { + color: tokens.$clickCheckboxColorCheckDisabled; + } +} diff --git a/src/components/Checkbox/Checkbox.stories.tsx b/src/components/Checkbox/Checkbox.stories.tsx index e6b5cee3e..1de764685 100644 --- a/src/components/Checkbox/Checkbox.stories.tsx +++ b/src/components/Checkbox/Checkbox.stories.tsx @@ -34,3 +34,136 @@ export const Playground: Story = { label: "Accept terms and conditions", }, }; + +export const Variations: Story = { + render: () => ( +
+
+

Variants

+
+ + + + + + + +
+
+ +
+

States

+
+ + + + + + +
+
+ +
+

Orientations

+
+ + +
+
+ +
+

Label Position

+
+ + +
+
+ +
+

Without Label

+
+ + +
+
+
+ ), + parameters: { + controls: { disable: true }, + actions: { disable: true }, + pseudo: { + hover: [".cuiCheckInput"], + focus: [".cuiCheckInput"], + }, + chromatic: { + delay: 300, + }, + }, +}; diff --git a/src/components/Checkbox/Checkbox.test.tsx b/src/components/Checkbox/Checkbox.test.tsx index 452ec2789..2e8f170c4 100644 --- a/src/components/Checkbox/Checkbox.test.tsx +++ b/src/components/Checkbox/Checkbox.test.tsx @@ -30,8 +30,8 @@ describe("Checkbox", () => { const checkbox = getByTestId("checkbox"); - const computedStyle = window.getComputedStyle(checkbox); - expect(computedStyle.cursor).toBe("not-allowed"); + // Check if disabled attribute is set + expect(checkbox).toBeDisabled(); fireEvent.click(checkbox); diff --git a/src/components/Checkbox/Checkbox.tsx b/src/components/Checkbox/Checkbox.tsx index 8578b4918..7a8def0a2 100644 --- a/src/components/Checkbox/Checkbox.tsx +++ b/src/components/Checkbox/Checkbox.tsx @@ -1,8 +1,11 @@ +"use client"; + import { GenericLabel, Icon } from "@/components"; import * as RadixCheckbox from "@radix-ui/react-checkbox"; import { ReactNode, useId } from "react"; -import { styled } from "styled-components"; -import { FormRoot } from "../commonElement"; +import clsx from "clsx"; +import { capitalize } from "../../utils/capitalize"; +import styles from "./Checkbox.module.scss"; export type CheckboxVariants = | "default" @@ -24,11 +27,6 @@ export interface CheckboxProps extends RadixCheckbox.CheckboxProps { dir?: "start" | "end"; } -const Wrapper = styled(FormRoot)` - align-items: center; - max-width: fit-content; -`; - export const Checkbox = ({ id, label, @@ -37,30 +35,68 @@ export const Checkbox = ({ orientation = "horizontal", dir = "end", checked, + className, ...delegated }: CheckboxProps) => { const defaultId = useId(); + + const variantClass = `cuiVariant${capitalize(variant)}`; + const orientationClass = `cuiOrientation${capitalize(orientation)}`; + const dirClass = `cuiDir${capitalize(dir)}`; + const checkedClass = + checked === true + ? "cuiCheckedTrue" + : checked === "indeterminate" + ? "cuiCheckedIndeterminate" + : ""; + const disabledClass = disabled ? "cuiDisabled" : ""; + return ( - - - + - - + + {label && ( )} - + ); }; - -const CheckInput = styled(RadixCheckbox.Root)<{ - variant: CheckboxVariants; -}>` - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - - ${({ theme, variant }) => ` - border-radius: ${theme.click.checkbox.radii.all}; - width: ${theme.click.checkbox.size.all}; - height: ${theme.click.checkbox.size.all}; - background: ${theme.click.checkbox.color.variations[variant].background.default}; - border: 1px solid ${theme.click.checkbox.color.variations[variant].stroke.default}; - cursor: pointer; - - &:hover { - background: ${theme.click.checkbox.color.variations[variant].background.hover}; - } - &[data-state="checked"], - &[data-state="indeterminate"] { - border-color: ${theme.click.checkbox.color.variations[variant].stroke.active}; - background: ${theme.click.checkbox.color.variations[variant].background.active}; - } - &[data-disabled] { - background: ${theme.click.checkbox.color.background.disabled}; - border-color: ${theme.click.checkbox.color.stroke.disabled}; - cursor: not-allowed; - &[data-state="checked"], - &[data-state="indeterminate"] { - background: ${theme.click.checkbox.color.background.disabled}; - border-color: ${theme.click.checkbox.color.stroke.disabled}; - } - } - `}; -`; - -const CheckIconWrapper = styled(RadixCheckbox.Indicator)` - ${({ theme }) => ` - color: ${theme.click.checkbox.color.check.active}; - &[data-disabled] { - color: ${theme.click.checkbox.color.check.disabled}; - } - `} -`; diff --git a/src/components/CodeBlock/CodeBlock.module.scss b/src/components/CodeBlock/CodeBlock.module.scss new file mode 100644 index 000000000..5ca48ef88 --- /dev/null +++ b/src/components/CodeBlock/CodeBlock.module.scss @@ -0,0 +1,71 @@ +@use "cui-mixins" as mixins; +@use "cui-variants" as variants; + +.cuiCodeBlockContainer { + @include mixins.cuiFullWidthStretch; + position: relative; + + --cui-codeblock-pre-background: light-dark( + tokens.$clickCodeblockLightModeColorBackgroundDefault, + tokens.$clickCodeblockDarkModeColorBackgroundDefault + ); + --cui-codeblock-pre-text: light-dark( + tokens.$clickCodeblockLightModeColorTextDefault, + tokens.$clickCodeblockDarkModeColorTextDefault + ); + + color: light-dark( + tokens.$clickCodeblockLightModeColorNumbersDefault, + tokens.$clickCodeblockDarkModeColorNumbersDefault + ); + + :global(.linenumber) { + color: light-dark( + tokens.$clickCodeblockLightModeColorNumbersDefault, + tokens.$clickCodeblockDarkModeColorNumbersDefault + ); + } + + @include variants.variant('cuiLightMode') { + color-scheme: light; + } + + @include variants.variant('cuiDarkMode') { + color-scheme: dark; + } +} + +.cuiCodeButton { + @include mixins.cuiEmptyButton; + + @include variants.variant('cuiCopied') { + color: tokens.$clickAlertColorTextSuccess; + } + + @include variants.variant('cuiError') { + color: tokens.$clickAlertColorTextDanger; + } + + @include variants.variant('cuiNormal') { + color: inherit; + } +} + +.cuiHighlighter { + background: transparent; + padding: 0; + margin: 0; +} + +.cuiCodeContent { + font-family: inherit; + color: inherit; +} + +.cuiButtonContainer { + position: absolute; + display: flex; + gap: 0.625rem; + top: tokens.$clickCodeblockSpaceY; + right: tokens.$clickCodeblockSpaceX; +} diff --git a/src/components/CodeBlock/CodeBlock.stories.ts b/src/components/CodeBlock/CodeBlock.stories.ts deleted file mode 100644 index ea82cd8db..000000000 --- a/src/components/CodeBlock/CodeBlock.stories.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Meta, StoryObj } from "@storybook/react-vite"; -import { CodeBlock } from "./CodeBlock"; - -const meta: Meta = { - component: CodeBlock, - title: "CodeBlocks/CodeBlock", - tags: ["code-blocks", "code-block", "autodocs"], -}; - -export default meta; - -type Story = StoryObj; - -export const Playground: Story = { - args: { - children: `SELECT - customer_id, - COUNT(DISTINCT order_id) AS total_orders, - SUM(quantity) AS total_quantity, - SUM(quantity * price) AS total_spent, - MIN(order_date) AS first_order_date, - MAX(order_date) AS last_order_date, - arrayJoin(arraySort(groupArray((order_date, product_id)))) AS ordered_products -FROM - orders -WHERE - order_date BETWEEN '2022-01-01' AND '2022-12-31' -GROUP BY - customer_id -HAVING - total_orders > 5 AND total_spent > 1000 -ORDER BY - total_spent DESC -LIMIT - 10; - `, - language: "sql", - showLineNumbers: true, - showWrapButton: false, - wrapLines: false, - }, -}; diff --git a/src/components/CodeBlock/CodeBlock.stories.tsx b/src/components/CodeBlock/CodeBlock.stories.tsx new file mode 100644 index 000000000..97bae1c1c --- /dev/null +++ b/src/components/CodeBlock/CodeBlock.stories.tsx @@ -0,0 +1,232 @@ +import { Meta, StoryObj } from "@storybook/react-vite"; +import { CodeBlock } from "./CodeBlock"; + +const meta: Meta = { + component: CodeBlock, + title: "CodeBlocks/CodeBlock", + tags: ["code-blocks", "code-block", "autodocs"], +}; + +export default meta; + +type Story = StoryObj; + +export const Playground: Story = { + args: { + children: `SELECT + customer_id, + COUNT(DISTINCT order_id) AS total_orders, + SUM(quantity) AS total_quantity, + SUM(quantity * price) AS total_spent, + MIN(order_date) AS first_order_date, + MAX(order_date) AS last_order_date, + arrayJoin(arraySort(groupArray((order_date, product_id)))) AS ordered_products +FROM + orders +WHERE + order_date BETWEEN '2022-01-01' AND '2022-12-31' +GROUP BY + customer_id +HAVING + total_orders > 5 AND total_spent > 1000 +ORDER BY + total_spent DESC +LIMIT + 10; + `, + language: "sql", + showLineNumbers: true, + showWrapButton: false, + wrapLines: false, + }, +}; + +export const Variations: Story = { + render: () => ( +
+
+

SQL with Line Numbers

+
+ + {`SELECT + customer_id, + COUNT(DISTINCT order_id) AS total_orders, + SUM(quantity) AS total_quantity +FROM orders +WHERE order_date BETWEEN '2022-01-01' AND '2022-12-31' +GROUP BY customer_id +ORDER BY total_orders DESC +LIMIT 10;`} + +
+
+ +
+

SQL without Line Numbers

+
+ + {"SELECT * FROM users WHERE active = true;"} + +
+
+ +
+

Bash Script

+
+ + {`#!/bin/bash +echo "Starting deployment..." +npm install +npm run build +docker build -t myapp:latest . +docker push myapp:latest +echo "Deployment complete!"`} + +
+
+ +
+

JSON Data

+
+ + {`{ + "name": "click-ui", + "version": "1.0.0", + "dependencies": { + "react": "^18.2.0", + "typescript": "^5.0.0" + }, + "scripts": { + "dev": "vite", + "build": "tsc && vite build" + } +}`} + +
+
+ +
+

TypeScript / TSX

+
+ + {`interface User { + id: number; + name: string; + email: string; +} + +const UserCard: React.FC<{ user: User }> = ({ user }) => { + return ( +
+

{user.name}

+

{user.email}

+
+ ); +};`} +
+
+
+ +
+

With Wrap Button

+
+ + { + "SELECT customer_id, first_name, last_name, email, phone_number, address, city, state, zip_code, country FROM customers WHERE registration_date >= '2023-01-01' ORDER BY last_name, first_name;" + } + +
+
+ +
+

With Wrap Lines Enabled

+
+ + { + "SELECT customer_id, first_name, last_name, email, phone_number, address, city, state, zip_code, country FROM customers WHERE registration_date >= '2023-01-01' ORDER BY last_name, first_name;" + } + +
+
+ +
+

Short Code Snippet

+
+ + {"npm install click-ui"} + +
+
+ +
+

Multi-language Examples

+
+
+

SQL

+ + {`SELECT COUNT(*) +FROM users +WHERE active = true;`} + +
+
+

Bash

+ + {`#!/bin/bash +echo "Hello, World!" +date`} + +
+
+
+
+ ), + parameters: { + controls: { disable: true }, + actions: { disable: true }, + pseudo: { + hover: [".cuiCodeButton"], + }, + }, +}; diff --git a/src/components/CodeBlock/CodeBlock.tsx b/src/components/CodeBlock/CodeBlock.tsx index 337e7ef3e..a145cb96e 100644 --- a/src/components/CodeBlock/CodeBlock.tsx +++ b/src/components/CodeBlock/CodeBlock.tsx @@ -1,13 +1,17 @@ -import { HTMLAttributes, useState } from "react"; +"use client"; + +import { ComponentPropsWithoutRef, useState } from "react"; import { Light as SyntaxHighlighter, createElement } from "react-syntax-highlighter"; +import clsx from "clsx"; import { IconButton } from "@/components"; -import { styled } from "styled-components"; import useColorStyle from "./useColorStyle"; -import { EmptyButton } from "../commonElement"; +import styles from "./CodeBlock.module.scss"; import sql from "react-syntax-highlighter/dist/cjs/languages/hljs/sql"; import bash from "react-syntax-highlighter/dist/cjs/languages/hljs/bash"; import json from "react-syntax-highlighter/dist/cjs/languages/hljs/json"; import tsx from "react-syntax-highlighter/dist/cjs/languages/hljs/typescript"; +import { useClickUITheme } from "@/theme"; +import { capitalize } from "@/utils/capitalize"; SyntaxHighlighter.registerLanguage("sql", sql); SyntaxHighlighter.registerLanguage("bash", bash); @@ -15,7 +19,7 @@ SyntaxHighlighter.registerLanguage("json", json); SyntaxHighlighter.registerLanguage("tsx", tsx); export type CodeThemeType = "light" | "dark"; -interface Props extends Omit, "children" | "onCopy"> { +interface Props extends Omit, "children" | "onCopy"> { language?: string; children: string; theme?: CodeThemeType; @@ -39,60 +43,6 @@ interface CustomRendererProps { useInlineStyles: boolean; } -const CodeBlockContainer = styled.div<{ $theme?: CodeThemeType }>` - width: 100%; - width: -webkit-fill-available; - width: fill-available; - width: stretch; - position: relative; - ${({ theme, $theme }) => { - const themeName = theme.name as CodeThemeType; - - const codeTheme = theme.click.codeblock[`${!$theme ? themeName : $theme}Mode`].color; - return ` - color: ${codeTheme.numbers.default}; - .linenumber { - color: ${codeTheme.numbers.default} - } - `; - }} -`; - -const CodeButton = styled(EmptyButton)<{ $copied: boolean; $error: boolean }>` - ${({ $copied, $error, theme }) => ` - color: ${ - $copied - ? theme.click.alert.color.text.success - : $error - ? theme.click.alert.color.text.danger - : "inherit" - }; - padding: 0; - border: 0; - `} -`; - -const Highlighter = styled(SyntaxHighlighter)` - background: transparent; - padding: 0; - margin: 0; -`; - -const CodeContent = styled.code` - font-family: inherit; - color: inherit; -`; - -const ButtonContainer = styled.div` - position: absolute; - display: flex; - ${({ theme }) => ` - gap: 0.625rem; - top: ${theme.click.codeblock.space.y}; - right: ${theme.click.codeblock.space.x}; - `} -`; - export const CodeBlock = ({ children, language, @@ -102,12 +52,16 @@ export const CodeBlock = ({ wrapLines = false, onCopy, onCopyError, + className, ...props }: Props) => { const [copied, setCopied] = useState(false); const [errorCopy, setErrorCopy] = useState(false); const [wrap, setWrap] = useState(wrapLines); - const customStyle = useColorStyle(theme); + const { resolvedTheme } = useClickUITheme(); + const themeMode = theme ?? resolvedTheme; + + const customStyle = useColorStyle(themeMode); const copyCodeToClipboard = async () => { try { @@ -129,38 +83,50 @@ export const CodeBlock = ({ setTimeout(() => setErrorCopy(false), 2000); } }; + const wrapElement = () => { setWrap(wrap => !wrap); }; - const CodeWithRef = (props: HTMLAttributes) => ; + const CodeWithRef = (props: ComponentPropsWithoutRef<"div">) => ( + + ); + return ( - - +
{showWrapButton && ( - )} - - - + { return rows.map((row, index) => { const children = row.children; @@ -197,7 +163,7 @@ export const CodeBlock = ({ wrapLongLines={wrap || wrapLines} > {children} - - + +
); }; diff --git a/src/components/CodeBlock/InlineCodeBlock.module.scss b/src/components/CodeBlock/InlineCodeBlock.module.scss new file mode 100644 index 000000000..f1d303dd1 --- /dev/null +++ b/src/components/CodeBlock/InlineCodeBlock.module.scss @@ -0,0 +1,11 @@ +@use "cui-mixins" as mixins; +@use "cui-variants" as variants; + +.cuiInlineContainer { + background: tokens.$clickCodeInlineColorBackgroundDefault; + color: tokens.$clickCodeInlineColorTextDefault; + border: 1px solid tokens.$clickCodeInlineColorStrokeDefault; + font: tokens.$clickCodeInlineTypographyTextDefault; + border-radius: tokens.$clickCodeInlineRadiiAll; + padding: 0 tokens.$clickCodeInlineSpaceX; +} \ No newline at end of file diff --git a/src/components/CodeBlock/InlineCodeBlock.stories.ts b/src/components/CodeBlock/InlineCodeBlock.stories.ts deleted file mode 100644 index 23f53724d..000000000 --- a/src/components/CodeBlock/InlineCodeBlock.stories.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Meta, StoryObj } from "@storybook/react-vite"; -import { InlineCodeBlock } from "./InlineCodeBlock"; - -const meta: Meta = { - component: InlineCodeBlock, - title: "CodeBlocks/Inline", - tags: ["code-blocks", "inline", "autodocs"], -}; - -export default meta; - -type Story = StoryObj; - -export const Playground: Story = { - args: { - children: "Text Content", - }, -}; diff --git a/src/components/CodeBlock/InlineCodeBlock.stories.tsx b/src/components/CodeBlock/InlineCodeBlock.stories.tsx new file mode 100644 index 000000000..2e44d58c1 --- /dev/null +++ b/src/components/CodeBlock/InlineCodeBlock.stories.tsx @@ -0,0 +1,271 @@ +import { Meta, StoryObj } from "@storybook/react-vite"; +import { InlineCodeBlock } from "./InlineCodeBlock"; + +const meta: Meta = { + component: InlineCodeBlock, + title: "CodeBlocks/Inline", + tags: ["code-blocks", "inline", "autodocs"], +}; + +export default meta; + +type Story = StoryObj; + +export const Playground: Story = { + args: { + children: "Text Content", + }, +}; + +export const Variations: Story = { + render: () => ( +
+
+

Basic Usage

+
+

+ You can install the package using{" "} + npm install click-ui or{" "} + yarn add click-ui. +

+
+
+ +
+

Command Examples

+
+

+ Run npm start to start the development + server. +

+

+ Execute docker ps to list running + containers. +

+

+ Use git status to check your repository + status. +

+

+ Build with npm run build for production. +

+
+
+ +
+

Variable and Function Names

+
+

+ The useState hook is used for state + management. +

+

+ Set the isLoading variable to{" "} + true. +

+

+ Call the fetchData() function to retrieve + data. +

+

+ Access the user.email property. +

+
+
+ +
+

File Paths and URLs

+
+

+ The configuration file is located at{" "} + /etc/config/app.json. +

+

+ Navigate to{" "} + src/components/Button/Button.tsx. +

+

+ Visit https://clickhouse.com for more + information. +

+
+
+ +
+

SQL and Database

+
+

+ Use the SELECT statement to query data. +

+

+ The WHERE clause filters results. +

+

+ Join tables using INNER JOIN. +

+
+
+ +
+

Short vs Long Code Snippets

+
+

+ Short: npm i +

+

+ Medium: const greeting = "Hello, World!"; +

+

+ Long:{" "} + + function calculateTotal(items) return items.reduce((sum, item) => sum + + item.price, 0); + +

+
+
+ +
+

In Paragraphs

+
+

+ To get started with Click UI, first install the package using{" "} + npm install click-ui. Then, import the + components you need:{" "} + import Button from 'click-ui/Button'. You + can customize the theme prop to match your + design system. For more advanced usage, check the{" "} + src/examples directory. +

+
+
+ +
+

Keyboard Shortcuts

+
+

+ Press Ctrl+C to copy. +

+

+ Use Cmd+S to save. +

+

+ Hit Esc to close the dialog. +

+
+
+ +
+

Configuration Values

+
+

+ Set PORT=3000 in your environment + variables. +

+

+ Configure maxConnections: 100 in your + database settings. +

+

+ Enable debug: true for development mode. +

+
+
+ +
+

Mixed Content

+
+

+ To deploy your application, first build it using{" "} + npm run build, then create a Docker image + with docker build -t myapp .. Push the + image to your registry using{" "} + docker push myapp:latest, and finally + deploy it to your cluster with{" "} + kubectl apply -f deployment.yaml. Monitor + the deployment status using{" "} + kubectl get pods. +

+
+
+ +
+

Special Characters

+
+

+ Use {""} for JSX syntax. +

+

+ Template literals: {"`Hello, ${name}`"} +

+

+ Regular expression: /[a-z]+/gi +

+
+
+
+ ), + parameters: { + controls: { disable: true }, + actions: { disable: true }, + pseudo: {}, + }, +}; diff --git a/src/components/CodeBlock/InlineCodeBlock.tsx b/src/components/CodeBlock/InlineCodeBlock.tsx index d794aff3a..b976b7df5 100644 --- a/src/components/CodeBlock/InlineCodeBlock.tsx +++ b/src/components/CodeBlock/InlineCodeBlock.tsx @@ -1,16 +1,9 @@ -import { HTMLAttributes } from "react"; -import { styled } from "styled-components"; +import { ComponentPropsWithoutRef } from "react"; +import styles from "./InlineCodeBlock.module.scss"; -const InlineContainer = styled.span` - ${({ theme }) => ` - background: ${theme.click.codeInline.color.background.default}; - color: ${theme.click.codeInline.color.text.default}; - border: 1px solid ${theme.click.codeInline.color.stroke.default}; - font: ${theme.click.codeInline.typography.text.default}; - border-radius: ${theme.click.codeInline.radii.all}; - padding: 0 ${theme.click.codeInline.space.x}; - `} -`; -export const InlineCodeBlock = (props: HTMLAttributes) => ( - +export const InlineCodeBlock = (props: ComponentPropsWithoutRef<"span">) => ( + ); diff --git a/src/components/CodeBlock/useColorStyle.ts b/src/components/CodeBlock/useColorStyle.ts index 147916f0c..5d3e22365 100644 --- a/src/components/CodeBlock/useColorStyle.ts +++ b/src/components/CodeBlock/useColorStyle.ts @@ -1,19 +1,29 @@ -import { useTheme } from "styled-components"; import { CodeThemeType } from "./CodeBlock"; +import { useCUITheme } from "@/theme/ClickUIProvider"; -const useColorStyle = (defaultTheme?: CodeThemeType) => { - const theme = useTheme(); - const inheritedThemeName = theme.name as CodeThemeType; - const themeName = !defaultTheme ? inheritedThemeName : defaultTheme; - const codeTheme = theme.click.codeblock[`${themeName}Mode`].color; +const useColorStyle = (themeOverride?: CodeThemeType) => { + const { resolvedTheme, theme } = useCUITheme(); + + // Use theme override if provided, otherwise use resolved theme from context + const themeName = themeOverride ?? (resolvedTheme === "dark" ? "dark" : "light"); + + // Get theme-specific colors + const bgColor = + themeName === "dark" + ? theme.click.codeblock.darkMode.color.background.default + : theme.click.codeblock.lightMode.color.background.default; + const textColor = + themeName === "dark" + ? theme.click.codeblock.darkMode.color.text.default + : theme.click.codeblock.lightMode.color.text.default; return { hljs: { display: "block", overflowX: "auto", padding: `${theme.click.codeblock.space.y} ${theme.click.codeblock.space.x}`, - color: codeTheme.text.default, - background: codeTheme.background.default, + color: textColor, + background: bgColor, borderRadius: theme.click.codeblock.radii.all, font: theme.click.codeblock.typography.text.default, }, diff --git a/src/components/Collapsible/Collapsible.module.scss b/src/components/Collapsible/Collapsible.module.scss new file mode 100644 index 000000000..3eb65d681 --- /dev/null +++ b/src/components/Collapsible/Collapsible.module.scss @@ -0,0 +1,50 @@ +@use "cui-mixins" as mixins; +@use "cui-variants" as variants; + +.cuiCollapsibleContainer { + @include mixins.cuiFullWidthStretch; + + [data-trigger-icon] { + visibility: hidden; + transition: transform 150ms cubic-bezier(0.87, 0, 0.13, 1); + + &[data-open="true"] { + transform: rotate(90deg); + } + } + + [data-collapsible-header]:hover [data-trigger-icon] { + visibility: visible; + } +} + +.cuiHeaderContainer { + display: flex; + align-items: center; + user-select: none; + + @include variants.variant('cuiIndicatorDirStart') { + margin-left: 0; + } + + @include variants.variant('cuiIndicatorDirEnd') { + margin-left: tokens.$clickImageSmSizeWidth; + } +} + +.cuiTriggerButton { + @include mixins.cuiEmptyButton; + display: flex; + align-items: center; + cursor: inherit; +} + +.cuiContentWrapper { + @include variants.variant('cuiIndicatorDirStart') { + margin-left: tokens.$clickImageSmSizeWidth; + } + + @include variants.variant('cuiIndicatorDirEnd') { + margin-right: tokens.$clickImageSmSizeWidth; + } +} diff --git a/src/components/Collapsible/Collapsible.tsx b/src/components/Collapsible/Collapsible.tsx index edf1c7759..b43cf73b7 100644 --- a/src/components/Collapsible/Collapsible.tsx +++ b/src/components/Collapsible/Collapsible.tsx @@ -1,18 +1,21 @@ +"use client"; + import { createContext, useState, - HTMLAttributes, + ComponentPropsWithoutRef, MouseEvent, useContext, useEffect, forwardRef, } from "react"; -import { styled } from "styled-components"; +import clsx from "clsx"; +import { capitalize } from "@/utils/capitalize"; import { Icon, HorizontalDirection, IconName } from "@/components"; -import { EmptyButton } from "../commonElement"; import { IconWrapper } from "./IconWrapper"; +import styles from "./Collapsible.module.scss"; -export interface CollapsibleProps extends HTMLAttributes { +export interface CollapsibleProps extends ComponentPropsWithoutRef<"div"> { open?: boolean; onOpenChange?: (value: boolean) => void; } @@ -26,24 +29,12 @@ const NavContext = createContext({ open: false, onOpenChange: () => null, }); -const CollapsibleContainer = styled.div` - width: 100%; - [data-trigger-icon] { - visibility: hidden; - transition: transform 150ms cubic-bezier(0.87, 0, 0.13, 1); - &[data-open="true"] { - transform: rotate(90deg); - } - } - [data-collapsible-header]:hover [data-trigger-icon] { - visibility: visible; - } -`; export const Collapsible = ({ open: openProp, onOpenChange: onOpenChangeProp, children, + className, ...props }: CollapsibleProps) => { const [open, setOpen] = useState(openProp ?? false); @@ -65,19 +56,16 @@ export const Collapsible = ({ onOpenChange, }; return ( - +
{children} - +
); }; -const CollapsipleHeaderContainer = styled.div<{ $indicatorDir: HorizontalDirection }>` - margin-left: ${({ theme, $indicatorDir }) => - $indicatorDir === "start" ? 0 : theme.click.image.sm.size.width}; - user-select: none; -`; - -interface CollapsipleHeaderProps extends HTMLAttributes { +interface CollapsipleHeaderProps extends ComponentPropsWithoutRef<"div"> { icon?: IconName; iconDir?: HorizontalDirection; indicatorDir?: HorizontalDirection; @@ -93,14 +81,16 @@ const CollapsipleHeader = forwardRef( children, wrapInTrigger, onClick: onClickProp, + className, ...props }: CollapsipleHeaderProps, ref ) => { const { onOpenChange } = useContext(NavContext); + const indicatorDirClass = `cuiIndicatorDir${capitalize(indicatorDir)}`; + return ( - { if (wrapInTrigger && typeof onOpenChange === "function") { @@ -111,6 +101,8 @@ const CollapsipleHeader = forwardRef( } }} data-collapsible-header + className={clsx(styles.cuiHeaderContainer, styles[indicatorDirClass], className)} + data-cui-indicator-dir={indicatorDir} {...props} > {indicatorDir === "start" && } @@ -123,7 +115,7 @@ const CollapsipleHeader = forwardRef( )} {indicatorDir === "end" && } - + ); } ); @@ -131,16 +123,7 @@ const CollapsipleHeader = forwardRef( CollapsipleHeader.displayName = "CollapsibleHeader"; Collapsible.Header = CollapsipleHeader; -const CollapsipleTriggerButton = styled(EmptyButton)<{ - $indicatorDir: HorizontalDirection; -}>` - display: flex; - align-items: center; - font: inherit; - color: inherit; - cursor: inherit; -`; -interface CollapsipleTriggerProps extends HTMLAttributes { +interface CollapsipleTriggerProps extends ComponentPropsWithoutRef<"button"> { icon?: IconName; iconDir?: HorizontalDirection; indicatorDir?: HorizontalDirection; @@ -152,6 +135,7 @@ const CollapsipleTrigger = ({ indicatorDir = "start", icon, iconDir = "start", + className, ...props }: CollapsipleTriggerProps) => { const { open, onOpenChange } = useContext(NavContext); @@ -165,10 +149,10 @@ const CollapsipleTrigger = ({ }; return ( - {indicatorDir === "start" && ( @@ -195,33 +179,37 @@ const CollapsipleTrigger = ({ size="sm" /> )} - + ); }; CollapsipleTrigger.displayName = "CollapsibleTrigger"; Collapsible.Trigger = CollapsipleTrigger; -const CollapsibleContentWrapper = styled.div<{ $indicatorDir?: HorizontalDirection }>` - ${({ theme, $indicatorDir }) => - $indicatorDir - ? `${$indicatorDir === "start" ? "margin-left" : "margin-right"}: ${ - theme.click.image.sm.size.width - }` - : ""} -`; - const CollapsipleContent = ({ indicatorDir, + className, ...props -}: HTMLAttributes & { indicatorDir?: HorizontalDirection }) => { +}: ComponentPropsWithoutRef<"div"> & { indicatorDir?: HorizontalDirection }) => { const { open } = useContext(NavContext); if (!open) { return; } + + const indicatorDirClass = indicatorDir + ? `cuiIndicatorDir${capitalize(indicatorDir)}` + : undefined; + return ( - ); diff --git a/src/components/Collapsible/IconWrapper.module.scss b/src/components/Collapsible/IconWrapper.module.scss new file mode 100644 index 000000000..260fd0471 --- /dev/null +++ b/src/components/Collapsible/IconWrapper.module.scss @@ -0,0 +1,28 @@ +@use "cui-mixins" as mixins; + +.cuiLabelContainer { + display: flex; + align-items: center; + justify-content: flex-start; + width: 100%; + width: -webkit-fill-available; + width: fill-available; + width: stretch; + flex: 1; + gap: tokens.$clickSidebarNavigationItemDefaultSpaceGap; + overflow: hidden; +} + +.cuiEllipsisContainer { + display: flex; + white-space: nowrap; + overflow: hidden; + justify-content: flex-start; + gap: inherit; + flex: 1; + + & > *:not(button) { + overflow: hidden; + text-overflow: ellipsis; + } +} diff --git a/src/components/Collapsible/IconWrapper.tsx b/src/components/Collapsible/IconWrapper.tsx index 3af8de50a..f872100c3 100644 --- a/src/components/Collapsible/IconWrapper.tsx +++ b/src/components/Collapsible/IconWrapper.tsx @@ -1,32 +1,6 @@ import { ReactNode } from "react"; -import { styled } from "styled-components"; import { Icon, HorizontalDirection, IconName } from "@/components"; - -const LabelContainer = styled.span` - display: flex; - align-items: center; - justify-content: flex-start; - width: 100%; - width: -webkit-fill-available; - width: fill-available; - width: stretch; - flex: 1; - gap: ${({ theme }) => theme.click.sidebar.navigation.item.default.space.gap}; - overflow: hidden; -`; - -const EllipsisContainer = styled.span` - display: flex; - white-space: nowrap; - overflow: hidden; - justify-content: flex-start; - gap: inherit; - flex: 1; - & > *:not(button) { - overflow: hidden; - text-overflow: ellipsis; - } -`; +import styles from "./IconWrapper.module.scss"; export const IconWrapper = ({ icon, @@ -38,20 +12,20 @@ export const IconWrapper = ({ children: ReactNode; }) => { return ( - + {icon && iconDir === "start" && ( )} - {children} + {children} {icon && iconDir === "end" && ( )} - +
); }; diff --git a/src/components/ConfirmationDialog/ConfirmationDialog.module.scss b/src/components/ConfirmationDialog/ConfirmationDialog.module.scss new file mode 100644 index 000000000..f604cdb24 --- /dev/null +++ b/src/components/ConfirmationDialog/ConfirmationDialog.module.scss @@ -0,0 +1,17 @@ +@use "cui-mixins" as mixins; + +.cuiActionsWrapper { + display: flex; + justify-content: flex-end; + gap: tokens.$clickDialogSpaceGap; + + @include mixins.cuiMobile { + flex-direction: column; + } +} + +.cuiDialogContent { + overflow: hidden; + display: flex; + flex-direction: column; +} diff --git a/src/components/ConfirmationDialog/ConfirmationDialog.stories.tsx b/src/components/ConfirmationDialog/ConfirmationDialog.stories.tsx index 91cb2ca8f..3eaa6eb78 100644 --- a/src/components/ConfirmationDialog/ConfirmationDialog.stories.tsx +++ b/src/components/ConfirmationDialog/ConfirmationDialog.stories.tsx @@ -1,11 +1,10 @@ -import { Meta, StoryObj } from "@storybook/react-vite"; -import { GridCenter } from "../commonElement"; +import { GridCenter } from "@/components/commonElement"; import { ConfirmationDialog, ConfirmationDialogProps, } from "@/components/ConfirmationDialog/ConfirmationDialog"; -const ConfirmationDialogExample = ({ +const ConfirmationDialogComponent = ({ disabled, loading, message, @@ -33,8 +32,8 @@ const ConfirmationDialogExample = ({ ); -const meta: Meta = { - component: ConfirmationDialogExample, +export default { + component: ConfirmationDialogComponent, title: "Display/ConfirmationDialog", tags: ["autodocs", "confirmation dialog"], argTypes: { @@ -45,11 +44,7 @@ const meta: Meta = { }, }; -export default meta; - -type Story = StoryObj; - -export const Playground: Story = { +export const Playground = { args: { title: "Example dialog title", disabled: false, @@ -76,3 +71,117 @@ export const Playground: Story = { }, }, }; + +export const Variations = { + render: () => ( +
+
+

Primary Action Types

+
+ + + +
+
+ +
+

Button States

+
+ + + + + +
+
+ +
+

Header Variants

+
+ + + +
+
+ +
+

Custom Labels

+
+ + + +
+
+
+ ), + parameters: { + controls: { disable: true }, + actions: { disable: true }, + pseudo: { + hover: [".cuiDialogContent"], + focus: [".cuiDialogContent"], + focusVisible: [".cuiDialogContent"], + }, + docs: { + story: { + inline: false, + iframeHeight: 1200, + }, + }, + }, +}; diff --git a/src/components/ConfirmationDialog/ConfirmationDialog.tsx b/src/components/ConfirmationDialog/ConfirmationDialog.tsx index b0edbe3aa..29034a832 100644 --- a/src/components/ConfirmationDialog/ConfirmationDialog.tsx +++ b/src/components/ConfirmationDialog/ConfirmationDialog.tsx @@ -1,10 +1,13 @@ +"use client"; + import { Container, Dialog, Separator, Text } from "@/components"; -import { HTMLAttributes, ReactElement, ReactNode } from "react"; -import { styled } from "styled-components"; +import { ComponentPropsWithoutRef, ReactElement, ReactNode } from "react"; +import clsx from "clsx"; +import styles from "./ConfirmationDialog.module.scss"; type DialogPrimaryAction = "primary" | "danger"; -export interface ConfirmationDialogProps extends HTMLAttributes { +export interface ConfirmationDialogProps extends ComponentPropsWithoutRef<"div"> { /** Custom content to display instead of the message */ children?: ReactNode; /** Whether the confirm button is disabled */ @@ -31,21 +34,6 @@ export interface ConfirmationDialogProps extends HTMLAttributes title: string; } -const ActionsWrapper = styled.div` - display: flex; - justify-content: flex-end; - gap: ${props => props.theme.click.dialog.space.gap}; - @media (max-width: ${({ theme }) => theme.breakpoint.sizes.sm}) { - flex-direction: column; - } -`; - -const DialogContent = styled.div` - overflow: hidden; - display: flex; - flex-direction: column; -`; - export const ConfirmationDialog = ({ children, disabled, @@ -74,11 +62,11 @@ export const ConfirmationDialog = ({ } }} > - {message}} - +
- - +
+ ); }; diff --git a/src/components/Container/Container.module.scss b/src/components/Container/Container.module.scss new file mode 100644 index 000000000..35357359d --- /dev/null +++ b/src/components/Container/Container.module.scss @@ -0,0 +1,228 @@ +@use "cui-mixins" as mixins; +@use "cui-variants" as variants; + +.cuiContainer { + @include mixins.cuiContainerBase; + + flex-wrap: nowrap; + box-sizing: border-box; + + // Orientation with variant mixins + @include variants.variant('cuiOrientationHorizontal') { + @include mixins.cuiContainerOrientation("horizontal"); + } + + @include variants.variant('cuiOrientationVertical') { + @include mixins.cuiContainerOrientation("vertical"); + } + + // Alignment with variant mixins + @include variants.variant('cuiAlignStart') { + align-items: flex-start; + } + + @include variants.variant('cuiAlignCenter') { + align-items: center; + } + + @include variants.variant('cuiAlignEnd') { + align-items: flex-end; + } + + @include variants.variant('cuiAlignStretch') { + align-items: stretch; + } + + // Justify content with variant mixins + @include variants.variant('cuiJustifyStart') { + justify-content: flex-start; + } + + @include variants.variant('cuiJustifyCenter') { + justify-content: center; + } + + @include variants.variant('cuiJustifyEnd') { + justify-content: flex-end; + } + + @include variants.variant('cuiJustifySpaceBetween') { + justify-content: space-between; + } + + @include variants.variant('cuiJustifySpaceAround') { + justify-content: space-around; + } + + @include variants.variant('cuiJustifySpaceEvenly') { + justify-content: space-evenly; + } + + @include variants.variant('cuiJustifyLeft') { + justify-content: left; + } + + @include variants.variant('cuiJustifyRight') { + justify-content: right; + } + +// Width classes +.cuiFillWidth { + width: 100%; +} + +.cuiAutoWidth { + width: auto; +} + +// Height classes +.cuiFillHeight { + height: 100%; +} + + // Flex grow with variant mixins + @include variants.variant('cuiGrow0') { + flex-grow: 0; + } + + @include variants.variant('cuiGrow1') { + flex-grow: 1; + } + + @include variants.variant('cuiGrow2') { + flex-grow: 2; + } + + @include variants.variant('cuiGrow3') { + flex-grow: 3; + } + + @include variants.variant('cuiGrow4') { + flex-grow: 4; + } + + @include variants.variant('cuiGrow5') { + flex-grow: 5; + } + + @include variants.variant('cuiGrow6') { + flex-grow: 6; + } + + // Flex shrink with variant mixins + @include variants.variant('cuiShrink0') { + flex-shrink: 0; + } + + @include variants.variant('cuiShrink1') { + flex-shrink: 1; + } + + @include variants.variant('cuiShrink2') { + flex-shrink: 2; + } + + @include variants.variant('cuiShrink3') { + flex-shrink: 3; + } + + @include variants.variant('cuiShrink4') { + flex-shrink: 4; + } + + @include variants.variant('cuiShrink5') { + flex-shrink: 5; + } + + @include variants.variant('cuiShrink6') { + flex-shrink: 6; + } + + // Wrap with variant mixins + @include variants.variant('cuiWrapNowrap') { + flex-wrap: nowrap; + } + + @include variants.variant('cuiWrapWrap') { + flex-wrap: wrap; + } + + @include variants.variant('cuiWrapWrapReverse') { + flex-wrap: wrap-reverse; + } + + // Gap with variant mixins + @include variants.variant('cuiGapNone') { + @include mixins.cuiContainerGap("none"); + } + + @include variants.variant('cuiGapXxs') { + @include mixins.cuiContainerGap("xxs"); + } + + @include variants.variant('cuiGapXs') { + @include mixins.cuiContainerGap("xs"); + } + + @include variants.variant('cuiGapSm') { + @include mixins.cuiContainerGap("sm"); + } + + @include variants.variant('cuiGapMd') { + @include mixins.cuiContainerGap("md"); + } + + @include variants.variant('cuiGapLg') { + @include mixins.cuiContainerGap("lg"); + } + + @include variants.variant('cuiGapXl') { + @include mixins.cuiContainerGap("xl"); + } + + @include variants.variant('cuiGapXxl') { + @include mixins.cuiContainerGap("xxl"); + } + + // Padding with variant mixins + @include variants.variant('cuiPaddingNone') { + padding: tokens.$clickContainerSpaceNone; + } + + @include variants.variant('cuiPaddingXxs') { + padding: tokens.$clickContainerSpaceXxs; + } + + @include variants.variant('cuiPaddingXs') { + padding: tokens.$clickContainerSpaceXs; + } + + @include variants.variant('cuiPaddingSm') { + padding: tokens.$clickContainerSpaceSm; + } + + @include variants.variant('cuiPaddingMd') { + padding: tokens.$clickContainerSpaceMd; + } + + @include variants.variant('cuiPaddingLg') { + padding: tokens.$clickContainerSpaceLg; + } + + @include variants.variant('cuiPaddingXl') { + padding: tokens.$clickContainerSpaceXl; + } + + @include variants.variant('cuiPaddingXxl') { + padding: tokens.$clickContainerSpaceXxl; + } +} + +// Responsive class +.cuiResponsive { + @include mixins.cuiMobile { + width: 100% !important; + max-width: none !important; + flex-direction: column !important; + } +} diff --git a/src/components/Container/Container.stories.module.scss b/src/components/Container/Container.stories.module.scss new file mode 100644 index 000000000..3105eabc8 --- /dev/null +++ b/src/components/Container/Container.stories.module.scss @@ -0,0 +1,9 @@ +@use "cui-mixins" as mixins; +@use "cui-variants" as variants; + +.cuiGridCenter { + display: grid; + place-items: center; + width: 100%; + height: 100%; +} \ No newline at end of file diff --git a/src/components/Container/Container.stories.tsx b/src/components/Container/Container.stories.tsx index 11ce2496d..d80d840b9 100644 --- a/src/components/Container/Container.stories.tsx +++ b/src/components/Container/Container.stories.tsx @@ -1,26 +1,84 @@ -import { Meta, StoryObj } from "@storybook/react-vite"; import { Container } from "./Container"; -import { Text } from ".."; -import { styled } from "styled-components"; +import { Text } from "@/components"; +import styles from "./Container.stories.module.scss"; -const GridCenter = styled.div` - display: grid; - place-items: center; - width: 100%; - height: 100%; -`; +const ContainerExample = ({ ...props }) => { + return ( +
+ + Parent container + + Child + + + Child + + + Child + + +
+ ); +}; -const meta: Meta = { - component: Container, +export default { + component: ContainerExample, title: "Layout/Container", tags: ["container", "autodocs"], + argTypes: { + alignItems: { control: "select", options: ["start", "center", "end", "stretch"] }, + fillWidth: { control: "boolean" }, + gap: { + control: "select", + options: ["none", "xxs", "xs", "sm", "md", "lg", "xl", "xxl"], + }, + grow: { + control: "select", + options: ["0", "1", "2", "3", "4", "5", "6"], + }, + shrink: { + control: "select", + options: ["0", "1", "2", "3", "4", "5", "6"], + }, + isResponsive: { control: "boolean" }, + justifyContent: { + control: "select", + options: [ + "center", + "space-between", + "space-around", + "space-evenly", + "start", + "end", + "left", + "right", + ], + }, + orientation: { control: "radio", options: ["horizontal", "vertical"] }, + padding: { + control: "select", + options: ["none", "xxs", "xs", "sm", "md", "lg", "xl", "xxl"], + }, + wrap: { + control: "select", + options: ["nowrap", "wrap", "wrap-reverse"], + }, + }, }; -export default meta; - -type Story = StoryObj; - -export const Playground: Story = { +export const Playground = { args: { alignItems: "center", fillWidth: false, @@ -35,32 +93,462 @@ export const Playground: Story = { shrink: "0", wrap: "nowrap", }, - render: args => ( - - - Parent container - ( +
+
+

Orientation

+
- Child - - + Horizontal + Layout + Example + + + Vertical + Layout + Example + +
+
+ +
+

Gap Options

+
- Child - - + No + Gap + + + XS + Gap + + + SM + Gap + + + MD + Gap + + + LG + Gap + + + XL + Gap + +
+
+ +
+

Padding Options

+
- Child - - - + + No Padding + + + XS Padding + + + SM Padding + + + MD Padding + + + LG Padding + + + XL Padding + +
+
+ +
+

Alignment Options

+
+ + Start + Aligned + + + Center + Aligned + + + End + Aligned + + + Stretch + Aligned + +
+
+ +
+

Justify Content Options

+
+ + Start + Justified + + + Center + Justified + + + End + Justified + + + Space + Between + + + Space + Around + + + Space + Evenly + +
+
+ +
+

Wrap Options

+
+ + No + Wrap + Example + With + Many + Items + + + Wrap + Example + With + Many + Items + That + Will + Wrap + +
+
+ +
+

Grow & Shrink Options

+
+ + + Grow 1 + + + Grow 2 + + + Grow 3 + + + + + Shrink 0 + + + Shrink 1 + + +
+
+ +
+

Width Options

+
+ + Auto Width + + + Fill Width + + + Max Width 300px + + + Min Width 400px + +
+
+ +
+

Height Options

+
+ + Fill Height + + + Max Height 100px with scrolling content + Line 2 + Line 3 + Line 4 + +
+
+ +
+

Responsive Container

+
+ + Responsive + Container + That stacks on mobile + + + Non-responsive + Container + Stays horizontal + +
+
+ +
+

Complex Nested Example

+
+ + Parent Container (Vertical) + + Child 1 + Child 2 + Child 3 + + + Child A + Child B + + +
+
+
), + parameters: { + controls: { disable: true }, + actions: { disable: true }, + pseudo: {}, + }, }; diff --git a/src/components/Container/Container.tsx b/src/components/Container/Container.tsx index 8ac566d4a..9e895ec70 100644 --- a/src/components/Container/Container.tsx +++ b/src/components/Container/Container.tsx @@ -1,12 +1,14 @@ -import { styled } from "styled-components"; +import { ElementType, forwardRef } from "react"; +import clsx from "clsx"; +import { capitalize } from "@/utils/capitalize"; +import { Orientation } from "@/components/types"; import { - ComponentProps, - ComponentPropsWithRef, - ElementType, - ReactNode, - forwardRef, -} from "react"; -import { Orientation } from "@/components"; + PolymorphicComponent, + PolymorphicComponentProps, + PolymorphicProps, + PolymorphicRef, +} from "@/utils/polymorphic"; +import styles from "./Container.module.scss"; type AlignItemsOptions = "start" | "center" | "end" | "stretch"; export type GapOptions = "none" | "xxs" | "xs" | "sm" | "md" | "lg" | "xl" | "xxl"; @@ -23,10 +25,9 @@ type JustifyContentOptions = export type PaddingOptions = "none" | "xxs" | "xs" | "sm" | "md" | "lg" | "xl" | "xxl"; type WrapOptions = "nowrap" | "wrap" | "wrap-reverse"; -export interface ContainerProps { - /** Custom component to render as */ - component?: T; - /** Alignment of items along the cross axis */ +export interface ContainerProps< + T extends ElementType = "div", +> extends PolymorphicComponentProps { alignItems?: AlignItemsOptions; /** The content to display inside the container */ children?: React.ReactNode; @@ -62,10 +63,6 @@ export interface ContainerProps { overflow?: string; } -type ContainerPolymorphicComponent = ( - props: Omit, keyof T> & ContainerProps -) => ReactNode; - const _Container = ( { component, @@ -86,88 +83,72 @@ const _Container = ( maxHeight, minHeight, overflow, + className, + style, ...props - }: Omit, keyof T> & ContainerProps, - ref: ComponentPropsWithRef["ref"] + }: PolymorphicProps>, + ref: PolymorphicRef ) => { + const Component = component ?? "div"; + const defaultAlignItems = + alignItems ?? (orientation === "vertical" ? "start" : "center"); + + const orientationClass = `cuiOrientation${capitalize(orientation)}`; + const alignClass = `cuiAlign${capitalize(defaultAlignItems)}`; + const justifyClass = `cuiJustify${capitalize(justifyContent)}`; + const wrapClass = `cuiWrap${capitalize(wrap)}`; + const gapClass = `cuiGap${capitalize(gap)}`; + const paddingClass = `cuiPadding${capitalize(padding)}`; + const growClass = grow ? `cuiGrow${grow}` : undefined; + const shrinkClass = shrink ? `cuiShrink${shrink}` : undefined; + + const containerClasses = clsx( + styles.cuiContainer, + styles[orientationClass], + styles[alignClass], + styles[justifyClass], + styles[wrapClass], + styles[gapClass], + styles[paddingClass], + { + [styles.cuiFillWidth]: fillWidth, + [styles.cuiAutoWidth]: !fillWidth, + [styles.cuiFillHeight]: fillHeight, + [styles.cuiResponsive]: isResponsive, + [styles[growClass!]]: growClass, + [styles[shrinkClass!]]: shrinkClass, + }, + className + ); + + const inlineStyles = { + maxWidth: maxWidth ?? undefined, + minWidth: minWidth ?? undefined, + maxHeight: maxHeight ?? undefined, + minHeight: minHeight ?? undefined, + overflow: overflow ?? undefined, + ...style, + }; + return ( - {children} - + ); }; -const Wrapper = styled.div<{ - $alignItems: AlignItemsOptions; - $fillWidth?: boolean; - $gapSize: GapOptions; - $grow?: GrowShrinkOptions; - $shrink?: GrowShrinkOptions; - $isResponsive?: boolean; - $justifyContent: JustifyContentOptions; - $maxWidth?: string; - $minWidth?: string; - $orientation: Orientation; - $paddingSize: PaddingOptions; - $wrap: WrapOptions; - $fillHeight?: boolean; - $minHeight?: string; - $maxHeight?: string; - $overflow?: string; -}>` - display: flex; - ${({ $grow, $shrink }) => ` - ${$grow && `flex: ${$grow}`}; - ${$shrink && `flex-shrink: ${$shrink}`}; - `} - ${({ $fillHeight, $maxHeight, $minHeight }) => ` - ${$fillHeight && "height: 100%"}; - ${$maxHeight && `max-height: ${$maxHeight}`}; - ${$minHeight && `min-height: ${$minHeight}`}; - `} - ${({ $overflow }) => ` - ${$overflow && `overflow: ${$overflow}`}; - `} - flex-wrap: ${({ $wrap = "nowrap" }) => $wrap}; - gap: ${({ theme, $gapSize }) => theme.click.container.gap[$gapSize]}; - max-width: ${({ $maxWidth }) => $maxWidth ?? "none"}; - min-width: ${({ $minWidth }) => $minWidth ?? "auto"}; - padding: ${({ theme, $paddingSize }) => theme.click.container.space[$paddingSize]}; - width: ${({ $fillWidth = true }) => ($fillWidth === true ? "100%" : "auto")}; - flex-direction: ${({ $orientation = "horizontal" }) => - $orientation === "horizontal" ? "row" : "column"}; - align-items: ${({ $alignItems = "center" }) => $alignItems}; - justify-content: ${({ $justifyContent = "left" }) => - $justifyContent === "start" ? "start" : `${$justifyContent}`}; - - @media (max-width: ${({ theme }) => theme.breakpoint.sizes.md}) { - width: ${({ $isResponsive = true, $fillWidth = true }) => - $isResponsive === true ? "100%" : $fillWidth === true ? "100%" : "auto"}; - max-width: ${({ $isResponsive = true }) => - $isResponsive === true ? "none" : "auto"}; - flex-direction: ${({ $isResponsive = true }) => - $isResponsive === true ? "column" : "auto"}; - } -`; -export const Container: ContainerPolymorphicComponent = forwardRef(_Container); +export const Container: PolymorphicComponent = forwardRef(_Container); diff --git a/src/components/ContextMenu/ContextMenu.module.scss b/src/components/ContextMenu/ContextMenu.module.scss new file mode 100644 index 000000000..e26ebb97d --- /dev/null +++ b/src/components/ContextMenu/ContextMenu.module.scss @@ -0,0 +1,69 @@ +@use "cui-mixins" as mixins; +@use "cui-variants" as variants; + +.cuiRightMenuContent { + flex-direction: column; + z-index: 1; + + @include variants.variant('cuiShowArrow') { + &[data-side="bottom"] { + margin-top: -1px; + } + + &[data-side="top"] { + margin-bottom: -1px; + } + + &[data-side="left"] { + margin-right: -1px; + + .cuiPopoverArrow { + margin-right: 1rem; + } + } + + &[data-side="right"] { + margin-left: -1px; + + .cuiPopoverArrow { + margin-left: 1rem; + } + } + } +} + +.cuiRightMenuGroup { + width: 100%; + border-bottom: 1px solid tokens.$clickGenericMenuItemColorDefaultStrokeDefault; +} + +.cuiRightMenuSub { + border-bottom: 1px solid tokens.$clickGenericMenuItemColorDefaultStrokeDefault; +} + +.cuiGenericMenuItem { + @include mixins.cuiGenericMenuItem; +} + +.cuiGenericMenuPanel { + outline: none; + overflow: hidden; + display: flex; + align-items: flex-start; + pointer-events: auto; + border: 1px solid tokens.$clickGenericMenuPanelColorStrokeDefault; + background: tokens.$clickGenericMenuPanelColorBackgroundDefault; + box-shadow: tokens.$clickGenericMenuPanelShadowDefault; + border-radius: tokens.$clickGenericMenuPanelRadiiAll; +} + +.cuiContextMenu { + max-width: var(--radix-context-menu-content-available-width); + max-height: var(--radix-context-menu-content-available-height); +} + +.cuiArrow { + fill: tokens.$clickGenericMenuPanelColorBackgroundDefault; + stroke: tokens.$clickGenericMenuPanelColorStrokeDefault; + filter: drop-shadow(0 4px 6px rgb(0, 0, 0, 0.1)); +} diff --git a/src/components/ContextMenu/ContextMenu.stories.module.scss b/src/components/ContextMenu/ContextMenu.stories.module.scss new file mode 100644 index 000000000..650fbac4a --- /dev/null +++ b/src/components/ContextMenu/ContextMenu.stories.module.scss @@ -0,0 +1,14 @@ +@use "cui-mixins" as mixins; +@use "cui-variants" as variants; + +.cuiGridCenter { + display: grid; + place-items: center; + width: 100%; + height: 100%; +} + +.cuiTrigger { + @extend .cuiGridCenter; + border: 2px currentcolor dashed; +} \ No newline at end of file diff --git a/src/components/ContextMenu/ContextMenu.stories.tsx b/src/components/ContextMenu/ContextMenu.stories.tsx index a2b6ab99f..c5ed29e76 100644 --- a/src/components/ContextMenu/ContextMenu.stories.tsx +++ b/src/components/ContextMenu/ContextMenu.stories.tsx @@ -1,37 +1,19 @@ -import React from "react"; -import { Meta, StoryObj } from "@storybook/react-vite"; import { ContextMenuProps } from "@radix-ui/react-context-menu"; -import { ContextMenu, ContextMenuItemProps } from "./ContextMenu"; -import { styled } from "styled-components"; +import { ContextMenu } from "./ContextMenu"; +import styles from "./ContextMenu.stories.module.scss"; -interface ContextMenuExampleProps extends ContextMenuProps { +interface Props extends ContextMenuProps { disabled?: boolean; showArrow?: boolean; side: "top" | "right" | "left" | "bottom"; } -const GridCenter = styled.div` - display: grid; - place-items: center; - width: 100%; - height: 100%; -`; - -const Trigger = styled(GridCenter)` - border: 2px currentColor dashed; -`; - -const ContextMenuExample = ({ - showArrow, - disabled, - side, - ...props -}: ContextMenuExampleProps) => { +const ContextMenuExample = ({ showArrow, disabled, side, ...props }: Props) => { return ( - +
- ContextMenu Trigger +
ContextMenu Trigger
Content3 - Delete content
- +
); }; - -const meta: Meta = { +export default { component: ContextMenuExample, - subcomponents: { - "ContextMenu.Trigger": ContextMenu.Trigger as React.ComponentType, - "ContextMenu.Content": ContextMenu.Content as React.ComponentType, - "ContextMenu.SubTrigger": ContextMenu.SubTrigger as React.ComponentType, - "ContextMenu.Group": ContextMenu.Group as React.ComponentType, - "ContextMenu.Sub": ContextMenu.Sub as React.ComponentType, - "ContextMenu.Item": ContextMenu.Item as React.ComponentType, - }, title: "Display/ContextMenu", tags: ["form-field", "dropdown", "autodocs"], argTypes: { @@ -89,13 +61,268 @@ const meta: Meta = { }, }; -export default meta; - -type Story = StoryObj; - -export const Playground: Story = { +export const Playground = { args: { showArrow: true, side: "left", }, }; + +export const Variations = { + render: () => ( +
+
+

Item Types

+
+ + +
Default Items
+
+ + Default Item 1 + Default Item 2 + Default Item 3 + +
+ + + +
Danger Items
+
+ + Regular Item + Delete + Remove + +
+
+
+ +
+

Item States

+
+ + +
Item States
+
+ + Normal Item + Disabled Item + +
+
+
+ +
+

Items with Icons

+
+ + +
Icon Start
+
+ + Activity + Profile + Settings + +
+ + + +
Icon End
+
+ + + Activity + + + Profile + + + Settings + + +
+
+
+ +
+

Arrow Variants

+
+ + +
With Arrow
+
+ + Item 1 + Item 2 + +
+ + + +
Without Arrow
+
+ + Item 1 + Item 2 + +
+
+
+ +
+

Position Variants

+
+ + +
Top
+
+ + Item 1 + Item 2 + +
+ + + +
Bottom
+
+ + Item 1 + Item 2 + +
+ + + +
Left
+
+ + Item 1 + Item 2 + +
+ + + +
Right
+
+ + Item 1 + Item 2 + +
+
+
+ +
+

With Sub-menus

+
+ + +
Nested Menu
+
+ + Regular Item + + More Options + + Sub Item 1 + Sub Item 2 + Sub Item 3 + + + Delete + +
+
+
+ +
+

With Groups

+
+ + +
Grouped Items
+
+ + + Group 1 - Item 1 + Group 1 - Item 2 + + Ungrouped Item + Another Item + +
+
+
+
+ ), + parameters: { + controls: { disable: true }, + actions: { disable: true }, + pseudo: { + hover: [".cuiGenericMenuItem"], + focus: [".cuiGenericMenuItem"], + focusVisible: [".cuiGenericMenuItem"], + }, + }, +}; diff --git a/src/components/ContextMenu/ContextMenu.tsx b/src/components/ContextMenu/ContextMenu.tsx index 024bab386..7f6f84179 100644 --- a/src/components/ContextMenu/ContextMenu.tsx +++ b/src/components/ContextMenu/ContextMenu.tsx @@ -1,10 +1,12 @@ +"use client"; + import * as RightMenu from "@radix-ui/react-context-menu"; -import { styled } from "styled-components"; import { HorizontalDirection, Icon, IconName } from "@/components"; -import { Arrow, GenericMenuItem, GenericMenuPanel } from "../GenericMenu"; -import PopoverArrow from "../icons/PopoverArrow"; -import IconWrapper from "../IconWrapper/IconWrapper"; +import { IconWrapper } from "@/components"; import { forwardRef } from "react"; +import clsx from "clsx"; +import styles from "./ContextMenu.module.scss"; +import PopoverArrow from "../icons/PopoverArrow"; export const ContextMenu = (props: RightMenu.ContextMenuProps) => ( @@ -40,8 +42,8 @@ const ContextMenuSubTrigger = ({ ...props }: ContextMenuSubTriggerProps) => { return ( - - + ); }; @@ -72,35 +74,6 @@ type ContextMenuSubContentProps = RightMenu.MenuSubContentProps & { sub?: never; } & ArrowProps; -const RightMenuContent = styled(GenericMenuPanel)<{ $showArrow?: boolean }>` - flex-direction: column; - z-index: 1; - ${({ $showArrow }) => - $showArrow - ? ` - &[data-side="bottom"] { - margin-top: -1px; - } - &[data-side="top"] { - margin-bottom: -1px; - } - &[data-side="left"] { - margin-right: -1px; - .popover-arrow { - margin-right: 1rem; - } - } - } - &[data-side="right"] { - margin-left: -1px; - .popover-arrow { - margin-left: 1rem; - } - } - ` - : ""}; -`; - const ContextMenuContent = ({ sub, children, @@ -110,24 +83,28 @@ const ContextMenuContent = ({ const ContentElement = sub ? RightMenu.SubContent : RightMenu.Content; return ( - {showArrow && ( - - - + + )} {children} - + ); }; @@ -135,34 +112,31 @@ const ContextMenuContent = ({ ContextMenuContent.displayName = "ContextMenuContent"; ContextMenu.Content = ContextMenuContent; -const RightMenuGroup = styled(RightMenu.Group)` - width: 100%; - border-bottom: 1px solid - ${({ theme }) => theme.click.genericMenu.item.color.default.stroke.default}; -`; - const ContextMenuGroup = (props: RightMenu.ContextMenuGroupProps) => { - return ; + return ( + + ); }; ContextMenuGroup.displayName = "ContextMenuGroup"; ContextMenu.Group = ContextMenuGroup; -const RightMenuSub = styled(RightMenu.Sub)` - border-bottom: 1px solid - ${({ theme }) => theme.click.genericMenu.item.color.default.stroke.default}; -`; - const ContextMenuSub = ({ ...props }: RightMenu.ContextMenuGroupProps) => { - return ; + return ( + + ); }; ContextMenuSub.displayName = "ContextMenuSub"; ContextMenu.Sub = ContextMenuSub; export interface ContextMenuItemProps extends RightMenu.ContextMenuItemProps { - /** Icon to display in the menu item */ icon?: IconName; - /** The direction of the icon relative to the label */ iconDir?: HorizontalDirection; /** The type of the menu item */ type?: "default" | "danger"; @@ -176,9 +150,10 @@ const ContextMenuItem = ({ ...props }: ContextMenuItemProps) => { return ( - {children} - + ); }; diff --git a/src/components/ContextMenu/_mixins.scss b/src/components/ContextMenu/_mixins.scss new file mode 100644 index 000000000..fecb477cb --- /dev/null +++ b/src/components/ContextMenu/_mixins.scss @@ -0,0 +1,69 @@ +@use "../../styles/tokens-light-dark" as tokens; + +// Menu mixins for Click UI menu components (ContextMenu, Dropdown, etc.) + +// Generic menu item mixin - for menu items in ContextMenu, DropdownMenu, etc. +@mixin cuiGenericMenuItem { + display: flex; + width: 100%; + width: -moz-available; + width: -webkit-fill-available; + width: fill-available; + width: stretch; + align-items: center; + justify-content: flex-start; + cursor: default; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + outline: none; + position: relative; + + padding: tokens.$clickGenericMenuItemSpaceY tokens.$clickGenericMenuItemSpaceX; + gap: tokens.$clickGenericMenuItemSpaceGap; + font: tokens.$clickGenericMenuItemTypographyLabelDefault; + background: tokens.$clickGenericMenuItemColorDefaultBackgroundDefault; + color: tokens.$clickGenericMenuItemColorDefaultTextDefault; + + &[aria-selected] { + outline: none; + } + + &[data-highlighted] { + font: tokens.$clickGenericMenuItemTypographyLabelHover; + background: tokens.$clickGenericMenuItemColorDefaultBackgroundHover; + color: tokens.$clickGenericMenuItemColorDefaultTextHover; + cursor: pointer; + } + + &[data-state="open"], + &[data-state="checked"], + &[data-selected="true"] { + background: tokens.$clickGenericMenuItemColorDefaultBackgroundActive; + color: tokens.$clickGenericMenuItemColorDefaultTextActive; + font: tokens.$clickGenericMenuItemTypographyLabelActive; + } + + &[data-disabled] { + color: tokens.$clickGenericMenuItemColorDefaultTextDisabled; + font: tokens.$clickGenericMenuItemTypographyLabelDisabled; + pointer-events: none; + } + + &:visited { + color: tokens.$clickGenericMenuItemColorDefaultTextDefault; + + a { + color: tokens.$clickGenericMenuItemColorDefaultTextDefault; + } + } + + &:hover .dropdown-arrow, + &[data-state="open"] .dropdown-arrow { + left: 0.5rem; + } + + &[hidden] { + display: none; + } +} diff --git a/src/components/DateDetails/DateDetails.module.scss b/src/components/DateDetails/DateDetails.module.scss new file mode 100644 index 000000000..c51c1e7f9 --- /dev/null +++ b/src/components/DateDetails/DateDetails.module.scss @@ -0,0 +1,26 @@ +@use "cui-mixins" as mixins; + +.cuiUnderlinedTrigger { + // Match linkStyles from main branch + // Using sm size values since main hardcodes $size="sm" + color: tokens.$clickGlobalColorTextLinkDefault; + margin: 0; + text-decoration: none; + display: inline-flex; + gap: tokens.$clickLinkSpaceSmGap; + margin-right: tokens.$clickLinkSpaceSmGap; + align-items: center; + cursor: pointer; + + &:hover, + &:focus { + color: tokens.$clickGlobalColorTextLinkHover; + transition: var(--transition-default); + text-decoration: underline; + outline: none; + } + + &:visited { + color: tokens.$clickGlobalColorTextLinkDefault; + } +} diff --git a/src/components/DateDetails/DateDetails.stories.tsx b/src/components/DateDetails/DateDetails.stories.tsx index 32fc953cf..466fe7a7d 100644 --- a/src/components/DateDetails/DateDetails.stories.tsx +++ b/src/components/DateDetails/DateDetails.stories.tsx @@ -19,3 +19,196 @@ export const Playground: Story = { weight: "normal", }, }; + +export const Variations: Story = { + parameters: { + controls: { disable: true }, + actions: { disable: true }, + pseudo: { + hover: ".cuiUnderlinedTrigger", + focus: ".cuiUnderlinedTrigger", + active: ".cuiUnderlinedTrigger", + }, + chromatic: { + delay: 300, + }, + }, + render: () => ( +
+
+

Sizes

+
+ + + + + +
+
+ +
+

Weights

+
+ + + + +
+
+ +
+

Different Time Periods

+
+ + + + + + + +
+
+ +
+

Popover Positions

+
+
+ + Top + + +
+
+ + Right + + +
+
+ + Bottom + + +
+
+ + Left + + +
+
+
+ +
+

With System Timezone

+
+ + + +
+
+ +
+

Size & Weight Combinations

+
+ + + +
+
+ +
+

In Content Context

+
+

+ This item was last updated{" "} + and will + expire{" "} + + . +

+
+
+
+ ), +}; diff --git a/src/components/DateDetails/DateDetails.tsx b/src/components/DateDetails/DateDetails.tsx index 8bbb822b5..6f8697619 100644 --- a/src/components/DateDetails/DateDetails.tsx +++ b/src/components/DateDetails/DateDetails.tsx @@ -1,19 +1,20 @@ +"use client"; + import dayjs, { Dayjs } from "dayjs"; -import advancedFormat from "dayjs/plugin/advancedFormat"; -import duration from "dayjs/plugin/duration"; -import localizedFormat from "dayjs/plugin/localizedFormat"; -import relativeTime from "dayjs/plugin/relativeTime"; -import timezone from "dayjs/plugin/timezone"; -import updateLocale from "dayjs/plugin/updateLocale"; -import utc from "dayjs/plugin/utc"; -import { styled } from "styled-components"; +import advancedFormat from "dayjs/plugin/advancedFormat.js"; +import duration from "dayjs/plugin/duration.js"; +import localizedFormat from "dayjs/plugin/localizedFormat.js"; +import relativeTime from "dayjs/plugin/relativeTime.js"; +import timezone from "dayjs/plugin/timezone.js"; +import updateLocale from "dayjs/plugin/updateLocale.js"; +import utc from "dayjs/plugin/utc.js"; import { Popover } from "@/components/Popover/Popover"; import { Text } from "@/components/Typography/Text/Text"; -import { linkStyles, StyledLinkProps } from "@/components/Link/common"; import { GridContainer } from "@/components/GridContainer/GridContainer"; import { Container } from "@/components/Container/Container"; -import { TextSize, TextWeight } from "../commonTypes"; +import { TextSize, TextWeight } from "@/components/commonTypes"; +import styles from "./DateDetails.module.scss"; dayjs.extend(advancedFormat); dayjs.extend(duration); @@ -60,10 +61,6 @@ dayjs.updateLocale("en", { }, }); -const UnderlinedTrigger = styled(Popover.Trigger)` - ${linkStyles} -`; - const formatDateDetails = (date: Dayjs, timezone?: string): string => { const isCurrentYear = dayjs().year() === date.year(); const formatForCurrentYear = "MMM D, h:mm a"; @@ -137,17 +134,14 @@ export const DateDetails = ({ return ( - + {dayjs.utc(date).fromNow()} - + ` - ${({ $isActive, theme }) => { - return `border: ${theme.click.datePicker.dateOption.stroke} solid ${ - $isActive - ? theme.click.datePicker.dateOption.color.stroke.active - : theme.click.field.color.stroke.default - };`; - }} - - width: ${explicitWidth}; -}`; +import styles from "./Common.module.scss"; interface DatePickerInputProps { isActive: boolean; @@ -42,8 +33,11 @@ export const DatePickerInput = ({ selectedDate instanceof Date ? selectedDateFormatter.format(selectedDate) : ""; return ( - @@ -51,13 +45,13 @@ export const DatePickerInput = ({ - + ); }; @@ -112,114 +106,66 @@ export const DateRangePickerInput = ({ } return ( - - {formattedValue} - - + + ); }; -const DatePickerContainer = styled(Container)` - background: ${({ theme }) => - theme.click.datePicker.dateOption.color.background.default}; -`; - -const UnselectableTitle = styled.h2` - ${({ theme }) => ` - color: ${theme.click.datePicker.color.title.default}; - font: ${theme.click.datePicker.typography.title.default}; - `} - - user-select: none; -`; - -const DateTable = styled.table` - border-collapse: separate; - border-spacing: 0; - font: ${({ theme }) => theme.typography.styles.product.text.normal.md}; - table-layout: fixed; - user-select: none; - width: ${explicitWidth}; - - thead tr { - height: ${({ theme }) => theme.click.datePicker.dateOption.size.height}; - } - - tbody { - cursor: pointer; - } - - td, - th { - padding: 4px; - } -`; - -const DateTableHeader = styled.th` - ${({ theme }) => ` - color: ${theme.click.datePicker.color.daytitle.default}; - font: ${theme.click.datePicker.typography.daytitle.default}; - `} - - width: 14%; -`; - -export const DateTableCell = styled.td<{ +interface DateTableCellProps { $isCurrentMonth?: boolean; $isDisabled?: boolean; $isSelected?: boolean; $isToday?: boolean; -}>` - ${({ theme }) => ` - border: ${theme.click.datePicker.dateOption.stroke} solid ${theme.click.datePicker.dateOption.color.stroke.default}; - border-radius: ${theme.click.datePicker.dateOption.radii.default}; - font: ${theme.click.datePicker.dateOption.typography.label.default}; - `} - - ${({ $isCurrentMonth, $isDisabled, theme }) => - (!$isCurrentMonth || $isDisabled) && - ` - color: ${theme.click.datePicker.dateOption.color.label.disabled}; - font: ${theme.click.datePicker.dateOption.typography.label.disabled}; - `} - - ${({ $isSelected, theme }) => - $isSelected && - ` - background: ${theme.click.datePicker.dateOption.color.background.active}; - color: ${theme.click.datePicker.dateOption.color.label.active}; - `} - - - text-align: center; - - ${({ $isToday, theme }) => - $isToday && `font: ${theme.click.datePicker.dateOption.typography.label.active};`} - - &:hover { - ${({ $isDisabled, theme }) => - `border: ${theme.click.datePicker.dateOption.stroke} solid ${ - $isDisabled - ? theme.click.datePicker.dateOption.color.stroke.disabled - : theme.click.datePicker.dateOption.color.stroke.hover - }; - + children?: React.ReactNode; + onClick?: () => void; + onMouseEnter?: () => void; + onMouseLeave?: () => void; +} - border-radius: ${theme.click.datePicker.dateOption.radii.default};`}; - } -`; +export const DateTableCell = ({ + $isCurrentMonth, + $isDisabled, + $isSelected, + $isToday, + children, + onClick, + onMouseEnter, + onMouseLeave, + ...props +}: DateTableCellProps) => { + return ( + + {children} + + ); +}; export type Body = ReturnType["body"]; @@ -251,7 +197,8 @@ export const CalendarRenderer = ({ headerDate.setFullYear(year); return ( - - {headerDateFormatter.format(headerDate)} +

+ {headerDateFormatter.format(headerDate)} +

- + - {headers.weekDays.map(({ key, value: date }) => { + {headers?.weekDays?.map(({ key, value: date }) => { return ( - + ); })} {children(body)} - - +
{weekdayFormatter.format(date)} - +
+
); }; diff --git a/src/components/DatePicker/DatePicker.stories.tsx b/src/components/DatePicker/DatePicker.stories.tsx index 235f507d8..3e8a3b407 100644 --- a/src/components/DatePicker/DatePicker.stories.tsx +++ b/src/components/DatePicker/DatePicker.stories.tsx @@ -43,3 +43,73 @@ export default defaultStory; export const Playground = { ...defaultStory, }; + +export const Variations = { + render: () => ( +
+
+

States

+
+
+

Default

+ console.log("Selected:", date)} + placeholder="Select a date" + /> +
+
+

+ With Selected Date +

+ console.log("Selected:", date)} + /> +
+
+

Disabled

+ console.log("Selected:", date)} + placeholder="Select a date" + /> +
+
+
+ +
+

With Future Dates Disabled

+
+ console.log("Selected:", date)} + placeholder="Select a past date" + /> +
+
+ +
+

With Custom Placeholder

+
+ console.log("Selected:", date)} + placeholder="Choose your birthday" + /> +
+
+
+ ), + parameters: { + controls: { disable: true }, + actions: { disable: true }, + pseudo: { + hover: ['[data-testid="datepicker-input-container"]'], + focus: ['[data-testid="datepicker-input-container"]'], + }, + chromatic: { + delay: 300, + }, + }, +}; diff --git a/src/components/DatePicker/DatePicker.tsx b/src/components/DatePicker/DatePicker.tsx index 24d061c8c..3b01d919d 100644 --- a/src/components/DatePicker/DatePicker.tsx +++ b/src/components/DatePicker/DatePicker.tsx @@ -1,6 +1,8 @@ +"use client"; + import { useEffect, useState } from "react"; import { isSameDate, UseCalendarOptions } from "@h6s/calendar"; -import { Dropdown } from "../Dropdown/Dropdown"; +import { Dropdown } from "@/components"; import { Body, CalendarRenderer, DatePickerInput, DateTableCell } from "./Common"; interface CalendarProps { diff --git a/src/components/DatePicker/DateRangePicker.module.scss b/src/components/DatePicker/DateRangePicker.module.scss new file mode 100644 index 000000000..22bf1c337 --- /dev/null +++ b/src/components/DatePicker/DateRangePicker.module.scss @@ -0,0 +1,47 @@ +@use "cui-mixins" as mixins; +@use "cui-variants" as variants; + +.cuiPredefinedCalendarContainer { + align-items: start; + background: tokens.$clickPanelColorBackgroundMuted; +} + +.cuiPredefinedDatesContainer { + width: 275px; +} + +// left value of 276px is the width of the PredefinedDatesContainer + 1 pixel for border +.cuiCalendarRendererContainer { + border: tokens.$clickDatePickerDateOptionStroke solid tokens.$clickDatePickerDateOptionColorBackgroundRange; + border-radius: tokens.$clickDatePickerDateOptionRadiiDefault; + box-shadow: + lch(6.77% 0 0deg / 0.15) 4px 4px 6px -1px, + lch(6.77% 0 0deg / 0.15) 2px 2px 4px -1px; + left: 276px; + position: absolute; + top: 0; +} + +// Height of 221px is height the height the calendar needs to match the PredefinedDatesContainer +.cuiStyledCalendarRenderer { + border-radius: tokens.$clickDatePickerDateOptionRadiiDefault; + min-height: 221px; +} + +.cuiStyledDropdownItem { + min-height: 24px; +} + +// max-height of 210px allows the scrollable container to be a reasonble height that matches the calendar +.cuiScrollableContainer { + max-height: 210px; + overflow-y: auto; +} + +.cuiDateRangeTableCell { + @include variants.variant('cuiShowRangeIndicator') { + background: tokens.$clickDatePickerDateOptionColorBackgroundRange; + border: tokens.$clickDatePickerDateOptionStroke solid tokens.$clickDatePickerDateOptionColorBackgroundRange; + border-radius: 0; + } +} diff --git a/src/components/DatePicker/DateRangePicker.stories.tsx b/src/components/DatePicker/DateRangePicker.stories.tsx index 071917293..6fc34e3d2 100644 --- a/src/components/DatePicker/DateRangePicker.stories.tsx +++ b/src/components/DatePicker/DateRangePicker.stories.tsx @@ -18,7 +18,7 @@ export default meta; type Story = StoryObj; -export const Default: Story = { +export const Playground: Story = { args: { predefinedDatesList: [], }, @@ -42,141 +42,126 @@ export const Default: Story = { }, }; -export const DateRangeWithMaxRange: Story = { - args: { - maxRangeLength: 15, - predefinedDatesList: [], - }, - render: (args: Args) => { - const endDate = args.endDate ? new Date(args.endDate) : undefined; - const startDate = args.startDate ? new Date(args.startDate) : undefined; - - return ( - - ); - }, -}; - -export const DateRangeFutureStartDatesDisabled: Story = { - args: { - futureStartDatesDisabled: true, - predefinedDatesList: [], - }, -}; - -export const PredefinedDatesLastSixMonths: Story = { - render: (args: Args) => { - const endDate = args.endDate ? new Date(args.endDate) : undefined; - const startDate = args.startDate ? new Date(args.startDate) : undefined; - const predefinedDatesList = getPredefinedMonthsForDateRangePicker(-6); - - return ( - - ); - }, -}; - -export const PredefinedDatesNextSixMonths: Story = { - render: (args: Args) => { - const endDate = args.endDate ? new Date(args.endDate) : undefined; - const startDate = args.startDate ? new Date(args.startDate) : undefined; - const predefinedDatesList = getPredefinedMonthsForDateRangePicker(6); - - return ( - - ); - }, -}; - -export const PredefinedDatesArbitraryDates: Story = { - render: (args: Args) => { - const endDate = args.endDate ? new Date(args.endDate) : undefined; - const startDate = args.startDate ? new Date(args.startDate) : undefined; - const predefinedDatesList = [ - { startDate: new Date("04/14/2025"), endDate: new Date("05/14/2025") }, - { startDate: new Date("05/14/2025"), endDate: new Date("06/14/2025") }, - { startDate: new Date("06/14/2025"), endDate: new Date("07/14/2025") }, - ]; - - return ( - - ); - }, -}; - -export const PredefinedDatesScrollable: Story = { - render: (args: Args) => { - const endDate = args.endDate ? new Date(args.endDate) : undefined; - const startDate = args.startDate ? new Date(args.startDate) : undefined; - const predefinedDatesList = [ - { startDate: new Date("09/14/2024"), endDate: new Date("10/14/2024") }, - { startDate: new Date("10/14/2024"), endDate: new Date("11/14/2024") }, - { startDate: new Date("11/14/2024"), endDate: new Date("12/14/2024") }, - { startDate: new Date("12/14/2024"), endDate: new Date("01/14/2025") }, - { startDate: new Date("01/14/2025"), endDate: new Date("02/14/2025") }, - { startDate: new Date("02/14/2025"), endDate: new Date("03/14/2025") }, - { startDate: new Date("03/14/2025"), endDate: new Date("04/14/2025") }, - { startDate: new Date("04/14/2025"), endDate: new Date("05/14/2025") }, - { startDate: new Date("05/14/2025"), endDate: new Date("06/14/2025") }, - { startDate: new Date("06/14/2025"), endDate: new Date("07/14/2025") }, - ]; - - return ( - - ); +export const Variations: Story = { + render: () => ( +
+
+

States

+
+
+

Default

+ console.log("Selected:", start, end)} + placeholder="Select date range" + /> +
+
+

+ With Selected Range +

+ console.log("Selected:", start, end)} + /> +
+
+

Disabled

+ console.log("Selected:", start, end)} + placeholder="Select date range" + /> +
+
+
+ +
+

With Max Range Length

+
+
+

Max 7 Days

+ console.log("Selected:", start, end)} + placeholder="Max 7 days" + /> +
+
+

Max 15 Days

+ console.log("Selected:", start, end)} + placeholder="Max 15 days" + /> +
+
+
+ +
+

Date Restrictions

+
+
+

+ Future Dates Disabled +

+ console.log("Selected:", start, end)} + placeholder="Past dates only" + /> +
+
+

+ Future Start Dates Disabled +

+ console.log("Selected:", start, end)} + placeholder="Past start dates only" + /> +
+
+
+ +
+

With Predefined Dates

+
+
+

+ Last 3 Months +

+ console.log("Selected:", start, end)} + placeholder="Select from predefined" + /> +
+
+
+ +
+

With Custom Placeholder

+
+ console.log("Selected:", start, end)} + placeholder="Choose your date range" + /> +
+
+
+ ), + parameters: { + controls: { disable: true }, + actions: { disable: true }, + pseudo: { + hover: ['[data-testid="datepicker-input-container"]'], + focus: ['[data-testid="datepicker-input-container"]'], + }, + chromatic: { + delay: 300, + }, }, }; diff --git a/src/components/DatePicker/DateRangePicker.tsx b/src/components/DatePicker/DateRangePicker.tsx index f42e5cff0..ea3f8e17b 100644 --- a/src/components/DatePicker/DateRangePicker.tsx +++ b/src/components/DatePicker/DateRangePicker.tsx @@ -7,68 +7,18 @@ import { useState, } from "react"; import { isSameDate, UseCalendarOptions } from "@h6s/calendar"; -import { styled } from "styled-components"; -import { Dropdown } from "../Dropdown/Dropdown"; +import { Dropdown } from "@/components"; import { Body, CalendarRenderer, DateRangePickerInput, DateTableCell } from "./Common"; -import { Container } from "../Container/Container"; -import { Panel } from "../Panel/Panel"; -import { Icon } from "../Icon/Icon"; +import { Container } from "@/components"; +import { Panel } from "@/components"; +import { Icon } from "@/components"; import { DateRange, datesAreWithinMaxRange, isDateRangeTheWholeMonth, selectedDateFormatter, } from "./utils"; - -const PredefinedCalendarContainer = styled(Panel)` - align-items: start; - background: ${({ theme }) => theme.click.panel.color.background.muted}; -`; - -const PredefinedDatesContainer = styled(Container)` - width: 275px; -`; - -// left value of 276px is the width of the PredefinedDatesContainer + 1 pixel for border -const CalendarRendererContainer = styled.div` - border: ${({ theme }) => - `${theme.click.datePicker.dateOption.stroke} solid ${theme.click.datePicker.dateOption.color.background.range}`}; - border-radius: ${({ theme }) => theme.click.datePicker.dateOption.radii.default}; - box-shadow: - lch(6.77 0 0 / 0.15) 4px 4px 6px -1px, - lch(6.77 0 0 / 0.15) 2px 2px 4px -1px; - left: 276px; - position: absolute; - top: 0; -`; - -// Height of 221px is height the height the calendar needs to match the PredefinedDatesContainer -const StyledCalendarRenderer = styled(CalendarRenderer)` - border-radius: ${({ theme }) => theme.click.datePicker.dateOption.radii.default}; - min-height: 221px; -`; - -const StyledDropdownItem = styled(Dropdown.Item)` - min-height: 24px; -`; - -// max-height of 210px allows the scrollable container to be a reasonble height that matches the calendar -const ScrollableContainer = styled(Container)` - max-height: 210px; - overflow-y: auto; -`; - -const DateRangeTableCell = styled(DateTableCell)<{ - $shouldShowRangeIndicator?: boolean; -}>` - ${({ $shouldShowRangeIndicator, theme }) => - $shouldShowRangeIndicator && - ` - background: ${theme.click.datePicker.dateOption.color.background.range}; - border: ${theme.click.datePicker.dateOption.stroke} solid ${theme.click.datePicker.dateOption.color.background.range}; - border-radius: 0; - `} -`; +import styles from "./DateRangePicker.module.scss"; interface CalendarProps { calendarBody: Body; @@ -91,12 +41,6 @@ const Calendar = ({ startDate, endDate, }: CalendarProps) => { - const [hoveredDate, setHoveredDate] = useState(); - - const handleMouseOut = (): void => { - setHoveredDate(undefined); - }; - return calendarBody.value.map(({ key: weekKey, value: week }) => { return ( @@ -108,10 +52,6 @@ const Calendar = ({ const today = new Date(); const isCurrentDate = isSameDate(today, fullDate); - const isBetweenStartAndEndDates = Boolean( - startDate && endDate && fullDate > startDate && fullDate < endDate - ); - let isDisabled = false; if (futureDatesDisabled && fullDate > today) { isDisabled = true; @@ -129,15 +69,8 @@ const Calendar = ({ isDisabled = true; } - const shouldShowRangeIndicator = - !endDate && - Boolean( - startDate && hoveredDate && fullDate > startDate && fullDate < hoveredDate - ); - - const handleMouseEnter = () => { - setHoveredDate(fullDate); - }; + const handleMouseEnter = () => {}; + const handleMouseLeave = () => {}; const handleClick = () => { if (isDisabled) { @@ -145,8 +78,6 @@ const Calendar = ({ } setSelectedDate(fullDate); - // User has a date range selected and clicked a new date. - // This will cause the selected date to be reset, thus do not close the datepicker. if (startDate && endDate) { return; } @@ -163,10 +94,7 @@ const Calendar = ({ } }; return ( - {date} - + ); })} @@ -218,12 +146,16 @@ const PredefinedDates = ({ }; return ( - - + {predefinedDatesList.map(({ startDate, endDate }) => { const handleItemClick = () => { setStartDate(startDate); @@ -246,7 +178,8 @@ const PredefinedDates = ({ )} - ${selectedDateFormatter.format(endDate)}`.trim(); return ( - } - + ); })} - - +
+ Custom time period - - + + ); }; @@ -338,8 +274,6 @@ export const DateRangePicker = ({ const handleSelectDate = useCallback( (selectedDate: Date): void => { - // Start date and end date are selected, user clicks any date. - // Set start date to the selected date, clear the end date. if (selectedStartDate && selectedEndDate) { // If futureStartDatesDisabled is true, only set the selected date to the date clicked if it's before today if (futureStartDatesDisabled && selectedDate > new Date()) { @@ -353,14 +287,10 @@ export const DateRangePicker = ({ if (selectedStartDate) { if (isSameDate(selectedStartDate, selectedDate)) { - // Start date is selected, user clicks start date. - // Reset the start date. setSelectedStartDate(undefined); return; } - // Start date is selected, user clicks an earlier date. - // Set the earlier date to the new start date. if (selectedDate < selectedStartDate) { setSelectedStartDate(selectedDate); return; @@ -398,7 +328,8 @@ export const DateRangePicker = ({ {shouldShowPredefinedDates ? ( - {shouldShowCustomRange && ( - - +
+ {(body: Body) => ( )} - - + +
)} -
+ ) : ( {(body: Body) => ( diff --git a/src/components/Dialog/Dialog.module.scss b/src/components/Dialog/Dialog.module.scss new file mode 100644 index 000000000..03c585e9a --- /dev/null +++ b/src/components/Dialog/Dialog.module.scss @@ -0,0 +1,89 @@ +@use "cui-mixins" as mixins; +@use "cui-variants" as variants; + +@keyframes overlayShow { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } +} + +@keyframes contentShow { + 0% { + opacity: 0; + transform: translate(-50%, -48%) scale(0.96); + } + 100% { + opacity: 1; + transform: translate(-50%, -50%) scale(1); + } +} + +.cuiTrigger { + width: fit-content; + background: transparent; + border: none; + cursor: pointer; +} + +.cuiDialogOverlay { + background-color: tokens.$clickDialogColorOpaqueBackgroundDefault; + position: fixed; + inset: 0; + animation: overlayShow 150ms cubic-bezier(0.16, 1, 0.3, 1); +} + +.cuiContentArea { + background: tokens.$clickDialogColorBackgroundDefault; + border-radius: tokens.$clickDialogRadiiAll; + box-shadow: tokens.$clickDialogShadowDefault; + border: 1px solid tokens.$clickGlobalColorStrokeDefault; + width: 75%; + max-width: 670px; + position: fixed; + top: 50%; + left: 50%; + max-height: 75%; + overflow: auto; + transform: translate(-50%, -50%); + animation: contentShow 150ms cubic-bezier(0.16, 1, 0.3, 1); + outline: none; + + @include variants.variant('cuiRegularPadding') { + padding-block: tokens.$clickDialogSpaceY; + padding-inline: tokens.$clickDialogSpaceX; + } + + @include variants.variant('cuiReducedPadding') { + padding-block: var(--sizes-4); + padding-inline: var(--sizes-4); + } + + @include mixins.cuiMobile { + max-height: 100%; + border-radius: 0; + width: 100%; + } +} + +.cuiTitleArea { + display: flex; + align-items: center; + min-height: var(--sizes-9); /* 32px */ + + @include variants.variant('cuiSpaceBetween') { + justify-content: space-between; + } + + @include variants.variant('cuiFlexEnd') { + justify-content: flex-end; + } +} + +.cuiTitle { + font: tokens.$clickDialogTypographyTitleDefault; + padding: 0; + margin: 0; +} diff --git a/src/components/Dialog/Dialog.stories.module.scss b/src/components/Dialog/Dialog.stories.module.scss new file mode 100644 index 000000000..f4fd6837f --- /dev/null +++ b/src/components/Dialog/Dialog.stories.module.scss @@ -0,0 +1,20 @@ +@use "cui-mixins" as mixins; +@use "cui-variants" as variants; + +.cuiActionArea { + display: flex; + justify-content: flex-end; + gap: tokens.$clickDialogSpaceGap; +} + +.cuiTopNav { + position: absolute; + top: 0; + left: 0; + width: 100%; + padding-bottom: 12px; + display: flex; + align-items: center; + justify-content: flex-end; + border-bottom: 1px solid tokens.$clickSeparatorColorStrokeDefault; +} \ No newline at end of file diff --git a/src/components/Dialog/Dialog.stories.tsx b/src/components/Dialog/Dialog.stories.tsx index 9084d82e8..f64a11b76 100644 --- a/src/components/Dialog/Dialog.stories.tsx +++ b/src/components/Dialog/Dialog.stories.tsx @@ -1,34 +1,31 @@ -import React, { useState } from "react"; -import { Meta, StoryObj } from "@storybook/react-vite"; -import { GridCenter } from "../commonElement"; -import { Text } from "../Typography/Text/Text"; +import { useState } from "react"; +import { GridCenter } from "@/components/commonElement"; +import styles from "./Dialog.stories.module.scss"; +import { Text } from "@/components/Typography/Text/Text"; import { Dialog } from "./Dialog"; -import Separator from "../Separator/Separator"; -import { Spacer } from "../Spacer/Spacer"; -import { Button } from "../Button/Button"; -import { styled } from "styled-components"; -import { Link } from "../Link/Link"; +import Separator from "@/components/Separator/Separator"; +import { Spacer } from "@/components/Spacer/Spacer"; +import { Button } from "@/components/Button/Button"; +import { Link } from "@/components/Link/Link"; import { Container } from "@/components/Container/Container"; import { TextField } from "@/components/Input/TextField"; import { Icon } from "@/components/Icon/Icon"; -interface DialogExampleProps { - open?: boolean; - title?: string; - modal: boolean; - showClose: boolean; - forceMount?: boolean; - reducePadding?: boolean; -} - -const DialogExample = ({ +const DialogComponent = ({ open, title, modal, showClose, forceMount, reducePadding, -}: DialogExampleProps) => ( +}: { + open?: boolean; + title?: string; + modal: boolean; + showClose: boolean; + forceMount?: boolean; + reducePadding?: boolean; +}) => ( ); -const ActionArea = styled.div` - display: flex; - justify-content: flex-end; - gap: ${({ theme }) => theme.click.dialog.space.gap}; -`; +const ActionArea = ({ children }: { children: React.ReactNode }) => ( +
{children}
+); -const meta: Meta = { - component: DialogExample, - subcomponents: { - "Dialog.Trigger": Dialog.Trigger as React.ComponentType, - "Dialog.Content": Dialog.Content as React.ComponentType, - "Dialog.Close": Dialog.Close as React.ComponentType, - }, +export default { + component: DialogComponent, title: "Display/Dialog", tags: ["autodocs", "dialog"], argTypes: { @@ -81,15 +71,14 @@ const meta: Meta = { }, }; -export default meta; - -type Story = StoryObj; - -export const ModalDialog: Story = { +export const ModalDialog = { args: { title: "Example dialog title", showClose: true, open: true, + onOpenChange: () => { + console.log("ignored"); + }, reducePadding: false, }, parameters: { @@ -102,26 +91,34 @@ export const ModalDialog: Story = { }, }; -const TopNav = styled.div` - position: absolute; - top: 0; - left: 0; - width: 100%; - padding-bottom: 12px; - display: flex; - align-items: center; - justify-content: flex-end; - border-bottom: 1px solid ${({ theme }) => theme.click.separator.color.stroke.default}; -`; +const TopNav = ({ children }: { children: React.ReactNode }) => ( +
{children}
+); -export const ChatDialog: Story = { +export const ChatDialog = { args: { title: "", showClose: false, open: false, + onOpenChange: () => { + console.log("ignored"); + }, reducePadding: true, }, - render: ({ title, modal, showClose, forceMount, reducePadding }) => { + render: ({ + title, + modal, + showClose, + forceMount, + reducePadding, + }: { + open?: boolean; + title?: string; + modal: boolean; + showClose: boolean; + forceMount?: boolean; + reducePadding?: boolean; + }) => { const [open, setOpen] = useState(true); return ( @@ -176,3 +173,137 @@ export const ChatDialog: Story = { }, }, }; + +export const Variations = { + args: { + title: "Dialog Title", + showClose: true, + open: true, + modal: true, + reducePadding: false, + }, + render: () => ( +
+
+

Padding Variants

+
+ + + + This dialog has regular padding for standard content presentation. + + + + + + + + + + + + + + This dialog has reduced padding for more compact layouts. + + + + + + + + + +
+
+ +
+

Header Variants

+
+ + + Dialog with both title and close button. + + + + + + Dialog with title but no close button. + + + + + + Dialog with close button but no title. + + +
+
+ +
+

Overlay Variants

+
+ + + Dialog with dark overlay backdrop. + + + + + + Dialog without overlay backdrop. + + +
+
+
+ ), + parameters: { + controls: { disable: true }, + actions: { disable: true }, + pseudo: { + hover: [".cuiContentArea", ".cuiTrigger"], + focus: [".cuiContentArea", ".cuiTrigger"], + focusVisible: [".cuiContentArea", ".cuiTrigger"], + }, + docs: { + story: { + inline: false, + iframeHeight: 1200, + }, + }, + }, +}; diff --git a/src/components/Dialog/Dialog.tsx b/src/components/Dialog/Dialog.tsx index 393031e5b..9e024e744 100644 --- a/src/components/Dialog/Dialog.tsx +++ b/src/components/Dialog/Dialog.tsx @@ -1,25 +1,21 @@ +"use client"; + import { ReactNode } from "react"; import * as RadixDialog from "@radix-ui/react-dialog"; -import { keyframes, styled } from "styled-components"; +import clsx from "clsx"; import { Button, Icon, Spacer } from "@/components"; -import { CrossButton } from "../commonElement"; +import { CrossButton } from "@/components/commonElement"; import { ButtonProps } from "@/components/Button/Button"; +import styles from "./Dialog.module.scss"; export const Dialog = ({ children, ...props }: RadixDialog.DialogProps) => { return {children}; }; -// Dialog Trigger -const Trigger = styled(RadixDialog.Trigger)` - width: fit-content; - background: transparent; - border: none; - cursor: pointer; -`; - const DialogTrigger = ({ children, asChild, + className, ...props }: RadixDialog.DialogTriggerProps) => { if (asChild) { @@ -34,7 +30,14 @@ const DialogTrigger = ({ ); } // Use styled Trigger if not asChild - return {children}; + return ( + + {children} + + ); }; DialogTrigger.displayName = "DialogTrigger"; @@ -54,62 +57,6 @@ DialogClose.displayName = "DialogClose"; Dialog.Close = DialogClose; // Dialog Content -const overlayShow = keyframes({ - "0%": { opacity: 0 }, - "100%": { opacity: 1 }, -}); - -const contentShow = keyframes({ - "0%": { opacity: 0, transform: "translate(-50%, -48%) scale(.96)" }, - "100%": { opacity: 1, transform: "translate(-50%, -50%) scale(1)" }, -}); - -const DialogOverlay = styled(RadixDialog.Overlay)` - background-color: ${({ theme }) => theme.click.dialog.color.opaqueBackground.default}; - position: fixed; - inset: 0; - animation: ${overlayShow} 150ms cubic-bezier(0.16, 1, 0.3, 1); -`; - -const ContentArea = styled(RadixDialog.Content)<{ $reducePadding?: boolean }>` - background: ${({ theme }) => theme.click.dialog.color.background.default}; - border-radius: ${({ theme }) => theme.click.dialog.radii.all}; - padding-block: ${({ theme, $reducePadding = false }) => - $reducePadding ? theme.sizes[4] : theme.click.dialog.space.y}; - padding-inline: ${({ theme, $reducePadding = false }) => - $reducePadding ? theme.sizes[4] : theme.click.dialog.space.x}; - box-shadow: ${({ theme }) => theme.click.dialog.shadow.default}; - border: 1px solid ${({ theme }) => theme.click.global.color.stroke.default}; - width: 75%; - max-width: 670px; - position: fixed; - top: 50%; - left: 50%; - max-height: 75%; - overflow: auto; - transform: translate(-50%, -50%); - animation: ${contentShow} 150ms cubic-bezier(0.16, 1, 0.3, 1); - outline: none; - - @media (max-width: ${({ theme }) => theme.breakpoint.sizes.sm}) { - max-height: 100%; - border-radius: 0; - width: 100%; - } -`; - -const TitleArea = styled.div<{ $onlyClose?: boolean }>` - display: flex; - justify-content: ${({ $onlyClose }) => ($onlyClose ? "flex-end" : "space-between")}; - align-items: center; - min-height: ${({ theme }) => theme.sizes[9]}; // 32px -`; - -const Title = styled.h2` - font: ${({ theme }) => theme.click.dialog.typography.title.default}; - padding: 0; - margin: 0; -`; const CloseButton = ({ onClose }: { onClose?: () => void }) => ( @@ -150,6 +97,7 @@ const DialogContent = ({ container, showOverlay = true, reducePadding = false, + className, ...props }: DialogContentProps) => { return ( @@ -157,29 +105,48 @@ const DialogContent = ({ forceMount={forceMount} container={container} > - {showOverlay && } - } + {(title || showClose) && ( <> - - {title && {title}} +
+ {title && ( +

+ {title} +

+ )} {showClose && ( )} - +
)} {children} -
+ ); }; diff --git a/src/components/Dropdown/Dropdown.module.scss b/src/components/Dropdown/Dropdown.module.scss new file mode 100644 index 000000000..02936d94b --- /dev/null +++ b/src/components/Dropdown/Dropdown.module.scss @@ -0,0 +1,118 @@ +@use "cui-mixins" as mixins; +@use "cui-variants" as variants; + +/* Dropdown menu trigger */ +.cuiTrigger { + cursor: pointer; + width: fit-content; + + &:disabled { + cursor: not-allowed; + } +} + +/* Dropdown menu item (base for all items) */ +.cuiMenuItem { + @include mixins.cuiGenericMenuItem; + min-height: 32px; +} + +/* Sub trigger specific styles (extends menu item) */ +.cuiSubTrigger { + @extend .cuiMenuItem; + + &[data-state="open"] { + font: tokens.$clickGenericMenuItemTypographyLabelHover; + background: tokens.$clickGenericMenuItemColorDefaultBackgroundHover; + color: tokens.$clickGenericMenuItemColorDefaultTextHover; + cursor: pointer; + } +} + +/* Dropdown menu content panel */ +.cuiMenuContent { + outline: none; + overflow: hidden; + display: flex; + align-items: flex-start; + pointer-events: auto; + min-width: tokens.$clickGenericMenuItemSizeMinWidth; + flex-direction: column; + z-index: 1; + overflow-y: auto; + + /* Panel styles */ + border: 1px solid tokens.$clickGenericMenuPanelColorStrokeDefault; + background: tokens.$clickGenericMenuPanelColorBackgroundDefault; + box-shadow: tokens.$clickGenericMenuPanelShadowDefault; + border-radius: tokens.$clickGenericMenuPanelRadiiAll; + + /* Max width/height based on type */ + @include variants.variant('cuiDropdownMenu') { + max-width: var(--radix-dropdown-menu-content-available-width); + max-height: var(--radix-dropdown-menu-content-available-height); + } + + @include variants.variant('cuiContextMenu') { + max-width: var(--radix-context-menu-content-available-width); + max-height: var(--radix-context-menu-content-available-height); + } + + @include variants.variant('cuiPopover') { + max-width: var(--radix-popover-content-available-width); + max-height: var(--radix-popover-content-available-height); + } +} + +/* Arrow margin adjustments when showArrow is true */ +.cuiMenuContentWithArrow { + &[data-side="bottom"] { + margin-top: -1px; + } + + &[data-side="top"] { + margin-bottom: 1px; + } + + &[data-side="left"] { + margin-right: -1px; + } + + &[data-side="right"] { + margin-left: -1px; + } +} + +/* Menu group */ +.cuiMenuGroup { + width: 100%; + border-bottom: 1px solid tokens.$clickGenericMenuItemColorDefaultStrokeDefault; +} + +/* Menu sub */ +.cuiMenuSub { + border-bottom: 1px solid tokens.$clickGenericMenuItemColorDefaultStrokeDefault; +} + +/* Arrow styling */ +.cuiArrow { + fill: tokens.$clickGenericMenuPanelColorBackgroundDefault; + stroke: tokens.$clickGenericMenuPanelColorStrokeDefault; + + /* Default shadow for bottom placement (arrow points up) */ + filter: drop-shadow(0 4px 6px rgb(0, 0, 0, 0.1)); + + /* Invert shadow when menu is on top (arrow points down) */ + [data-side="top"] & { + filter: drop-shadow(0 -4px 6px rgb(0, 0, 0, 0.1)); + } + + /* Adjust shadow for left/right placements */ + [data-side="left"] & { + filter: drop-shadow(-4px 0 6px rgb(0, 0, 0, 0.1)); + } + + [data-side="right"] & { + filter: drop-shadow(4px 0 6px rgb(0, 0, 0, 0.1)); + } +} diff --git a/src/components/Dropdown/Dropdown.stories.tsx b/src/components/Dropdown/Dropdown.stories.tsx index 8337b4e55..dc54580b4 100644 --- a/src/components/Dropdown/Dropdown.stories.tsx +++ b/src/components/Dropdown/Dropdown.stories.tsx @@ -2,8 +2,8 @@ import React from "react"; import { Meta, StoryObj } from "@storybook/react-vite"; import { DropdownMenuProps } from "@radix-ui/react-dropdown-menu"; import { Dropdown } from "./Dropdown"; -import { GridCenter } from "../commonElement"; -import { Button } from ".."; +import { GridCenter } from "@/components/commonElement"; +import { Button } from "@/components"; import { Key } from "react"; import type { DropdownItemProps } from "./Dropdown"; @@ -138,3 +138,241 @@ export const Playground: Story = { itemCount: 0, }, }; + +export const Variations: Story = { + args: { + open: true, + defaultOpen: true, + }, + render: () => ( +
+
+

Item Types

+
+ + Default Items + + Default Item 1 + Default Item 2 + Default Item 3 + + + + + Danger Items + + Regular Item + Delete + Remove + + +
+
+ +
+

Item States

+
+ + Item States + + Normal Item + Disabled Item + Checked Item + + +
+
+ +
+

Items with Icons

+
+ + Icon Start + + Activity + Profile + Settings + + + + + Icon End + + + Activity + + + Profile + + + Settings + + + +
+
+ +
+

Arrow Variants

+
+ + With Arrow + + Item 1 + Item 2 + + + + + Without Arrow + + Item 1 + Item 2 + + +
+
+ +
+

Position Variants

+
+ + Top + + Item 1 + Item 2 + + + + + Bottom + + Item 1 + Item 2 + + + + + Left + + Item 1 + Item 2 + + + + + Right + + Item 1 + Item 2 + + +
+
+ +
+

With Sub-menus

+
+ + Nested Menu + + Regular Item + + More Options + + Sub Item 1 + Sub Item 2 + Sub Item 3 + + + Delete + + +
+
+ +
+

With Groups

+
+ + Grouped Items + + + Group 1 - Item 1 + Group 1 - Item 2 + + Ungrouped Item + Another Item + + +
+
+
+ ), + parameters: { + controls: { disable: true }, + actions: { disable: true }, + pseudo: { + hover: [".cuiMenuItem", ".cuiSubTrigger", ".cuiTrigger"], + focus: [".cuiMenuItem", ".cuiSubTrigger", ".cuiTrigger"], + focusVisible: [".cuiMenuItem", ".cuiSubTrigger", ".cuiTrigger"], + }, + }, +}; diff --git a/src/components/Dropdown/Dropdown.tsx b/src/components/Dropdown/Dropdown.tsx index 8a5ef2897..0e2464695 100644 --- a/src/components/Dropdown/Dropdown.tsx +++ b/src/components/Dropdown/Dropdown.tsx @@ -1,21 +1,17 @@ +"use client"; + import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; -import { styled } from "styled-components"; -import { Arrow, GenericMenuItem, GenericMenuPanel } from "../GenericMenu"; -import PopoverArrow from "../icons/PopoverArrow"; -import IconWrapper from "../IconWrapper/IconWrapper"; -import { HorizontalDirection, IconName } from "../types"; -import { Icon } from "../Icon/Icon"; +import clsx from "clsx"; +import PopoverArrow from "@/components/icons/PopoverArrow"; +import { IconWrapper } from "@/components"; +import { HorizontalDirection, IconName } from "@/components/types"; +import { Icon } from "@/components"; +import styles from "./Dropdown.module.scss"; export const Dropdown = (props: DropdownMenu.DropdownMenuProps) => ( ); -const DropdownMenuItem = styled(GenericMenuItem)<{ $type?: "default" | "danger" }>` - position: relative; - display: flex; - min-height: 32px; -`; - interface SubDropdownProps { sub?: true; icon?: IconName; @@ -30,13 +26,6 @@ type DropdownSubTriggerProps = DropdownMenu.DropdownMenuSubTriggerProps & SubDropdownProps; type DropdownTriggerProps = DropdownMenu.DropdownMenuTriggerProps & MainDropdownProps; -const Trigger = styled(DropdownMenu.Trigger)` - cursor: pointer; - width: fit-content; - &[disabled] { - cursor: not-allowed; - } -`; const DropdownTrigger = ({ sub, @@ -46,8 +35,8 @@ const DropdownTrigger = ({ if (sub) { const { icon, iconDir, ...menuProps } = props as DropdownSubTriggerProps; return ( - - + ); } return ( - -
{children}
-
+
{children}
+ ); }; @@ -83,13 +72,6 @@ type DropdownSubContentProps = DropdownMenu.MenuSubContentProps & MainDropdownProps & ArrowProps; -const DropdownMenuContent = styled(GenericMenuPanel)` - min-width: ${({ theme }) => theme.click.genericMenu.item.size.minWidth}; - flex-direction: column; - z-index: 1; - overflow-y: auto; -`; - const DropdownContent = ({ sub, children, @@ -97,29 +79,31 @@ const DropdownContent = ({ ...props }: DropdownContentProps | DropdownSubContentProps) => { const ContentElement = sub ? DropdownMenu.SubContent : DropdownMenu.Content; + const contentClasses = clsx(styles.cuiMenuContent, { + [styles.cuiMenuContentWithArrow]: showArrow, + [styles.cuiDropdownMenu]: !sub, + }); + return ( - {showArrow && ( - - - + + )} {children} - + ); }; @@ -127,41 +111,36 @@ const DropdownContent = ({ DropdownContent.displayName = "DropdownContent"; Dropdown.Content = DropdownContent; -const DropdownMenuGroup = styled(DropdownMenu.Group)` - width: 100%; - border-bottom: 1px solid - ${({ theme }) => theme.click.genericMenu.item.color.default.stroke.default}; -`; - const DropdownGroup = (props: DropdownMenu.DropdownMenuGroupProps) => { - return ; + return ( + + ); }; DropdownGroup.displayName = "DropdownGroup"; Dropdown.Group = DropdownGroup; -const DropdownMenuSub = styled(DropdownMenu.Sub)` - border-bottom: 1px solid - ${({ theme }) => theme.click.genericMenu.item.color.default.stroke.default}; -`; - const DropdownSub = ({ ...props }: DropdownMenu.DropdownMenuGroupProps) => { - return ; + return ( + + ); }; DropdownSub.displayName = "DropdownSub"; Dropdown.Sub = DropdownSub; -interface DropdownItemProps extends DropdownMenu.DropdownMenuItemProps { - /** Icon to display in the menu item */ +export interface DropdownItemProps extends DropdownMenu.DropdownMenuItemProps { icon?: IconName; - /** The direction of the icon relative to the label */ iconDir?: HorizontalDirection; /** The type of the menu item */ type?: "default" | "danger"; } - -export type { DropdownItemProps }; const DropdownItem = ({ icon, iconDir, @@ -170,9 +149,10 @@ const DropdownItem = ({ ...props }: DropdownItemProps) => { return ( - {children} - + ); }; diff --git a/src/components/EllipsisContent/EllipsisContent.module.scss b/src/components/EllipsisContent/EllipsisContent.module.scss new file mode 100644 index 000000000..445f26e41 --- /dev/null +++ b/src/components/EllipsisContent/EllipsisContent.module.scss @@ -0,0 +1,16 @@ +@use "cui-mixins" as mixins; + +.cuiEllipsisContainer { + display: inline-block; + white-space: nowrap; + text-overflow: ellipsis; + vertical-align: text-bottom; + overflow: hidden; + justify-content: flex-start; + @include mixins.cuiFullWidthStretch; + + & > *:not(button) { + overflow: hidden; + text-overflow: ellipsis; + } +} diff --git a/src/components/EllipsisContent/EllipsisContent.tsx b/src/components/EllipsisContent/EllipsisContent.tsx index 83b71af1d..2c3c5aa79 100644 --- a/src/components/EllipsisContent/EllipsisContent.tsx +++ b/src/components/EllipsisContent/EllipsisContent.tsx @@ -1,44 +1,27 @@ -import { - ComponentProps, - ComponentPropsWithRef, - ElementType, - ReactNode, - forwardRef, -} from "react"; +import { ElementType, forwardRef } from "react"; import { mergeRefs } from "@/utils/mergeRefs"; -import { styled } from "styled-components"; - -const EllipsisContainer = styled.div` - display: inline-block; - white-space: nowrap; - text-overflow: ellipsis; - vertical-align: text-bottom; - overflow: hidden; - justify-content: flex-start; - width: 100%; - width: -webkit-fill-available; - width: fill-available; - width: stretch; - & > *:not(button) { - overflow: hidden; - text-overflow: ellipsis; - } -`; -export interface EllipsisContentProps { - component?: T; -} +import clsx from "clsx"; +import { + PolymorphicComponent, + PolymorphicComponentProps, + PolymorphicProps, + PolymorphicRef, +} from "@/utils/polymorphic"; +import styles from "./EllipsisContent.module.scss"; -type EllipsisPolymorphicComponent = ( - props: Omit, keyof T> & EllipsisContentProps -) => ReactNode; +export interface EllipsisContentProps< + T extends ElementType = "div", +> extends PolymorphicComponentProps {} const _EllipsisContent = ( - { component, ...props }: Omit, keyof T> & EllipsisContentProps, - ref: ComponentPropsWithRef["ref"] + { component, className, ...props }: PolymorphicProps>, + ref: PolymorphicRef ) => { + const Component = component ?? "div"; + return ( - { @@ -52,4 +35,5 @@ const _EllipsisContent = ( ); }; -export const EllipsisContent: EllipsisPolymorphicComponent = forwardRef(_EllipsisContent); +export const EllipsisContent: PolymorphicComponent = + forwardRef(_EllipsisContent); diff --git a/src/components/FileTabs/FileTabs.module.scss b/src/components/FileTabs/FileTabs.module.scss new file mode 100644 index 000000000..3e6ec1e2b --- /dev/null +++ b/src/components/FileTabs/FileTabs.module.scss @@ -0,0 +1,164 @@ +@use "cui-mixins" as mixins; +@use "cui-variants" as variants; + +.cuiTabsContainer { + display: flex; + position: relative; + overflow: auto; + overscroll-behavior: none; + scrollbar-width: 0; + max-width: var(--dynamic-max-width); + + &::-webkit-scrollbar { + height: 0; + } +} + +.cuiTabsSortableContainer { + display: flex; + + & > div { + height: 100%; + outline: none; + min-width: 100px; + width: clamp(100px, 100%, 200px); + + &.sortable-ghost { + opacity: 0; + } + } +} + +.cuiTabElement { + display: grid; + justify-content: flex-start; + align-items: center; + outline: none; + @include mixins.cuiFullMaxWidthStretch; + border: none; + cursor: pointer; + height: 100%; + max-height: 100%; + box-sizing: border-box; + width: 100%; + padding: tokens.$clickTabsFileTabsSpaceY tokens.$clickTabsFileTabsSpaceX; + gap: tokens.$clickTabsFileTabsSpaceGap; + border-radius: tokens.$clickTabsFileTabsRadiiAll; + border-right: 1px solid tokens.$clickTabsFileTabsColorStrokeDefault; + background: tokens.$clickTabsFileTabsColorBackgroundDefault; + color: tokens.$clickTabsFileTabsColorTextDefault; + font: tokens.$clickTabsFileTabsTypographyLabelDefault; + + svg, + [data-indicator] { + height: tokens.$clickTabsFileTabsIconSizeHeight; + width: tokens.$clickTabsFileTabsIconSizeWidth; + } + + [data-type="close"] { + display: none; + } + + [data-indicator] { + display: block; + } + + &:hover { + [data-type="close"] { + display: block; + } + + [data-indicator] { + display: none; + } + } + + @include variants.variant('cuiActive') { + background: tokens.$clickTabsFileTabsColorBackgroundActive; + color: tokens.$clickTabsFileTabsColorTextActive; + font: tokens.$clickTabsFileTabsTypographyLabelActive; + border-right: 1px solid tokens.$clickTabsFileTabsColorStrokeActive; + } + + &:not(.cuiActive):hover { + background: tokens.$clickTabsFileTabsColorBackgroundHover; + color: tokens.$clickTabsFileTabsColorTextHover; + font: tokens.$clickTabsFileTabsTypographyLabelHover; + border-right: 1px solid tokens.$clickTabsFileTabsColorStrokeHover; + } + + @include variants.variant('cuiPreview') { + font-style: italic; + } + + @include variants.variant('cuiDismissable') { + grid-template-columns: 1fr tokens.$clickTabsFileTabsIconSizeWidth; + } + + @include variants.variant('cuiFixedTabElement') { + width: auto; + } +} + +.cuiIndicator { + position: relative; + + &::after { + position: absolute; + left: 0.25rem; + top: 0.25rem; + content: ""; + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + } + + &.default::after { + background: transparent; + } + + &.success::after { + background: tokens.$clickAlertColorTextSuccess; + } + + &.neutral::after { + background: tokens.$clickAlertColorTextNeutral; + } + + &.danger::after { + background: tokens.$clickAlertColorTextDanger; + } + + &.warning::after { + background: tokens.$clickAlertColorTextWarning; + } + + &.info::after { + background: tokens.$clickAlertColorTextInfo; + } +} + +.cuiTabContent { + display: flex; + justify-content: flex-start; + align-items: center; + flex-wrap: nowrap; + overflow: hidden; + gap: tokens.$clickTabsFileTabsSpaceGap; +} + +.cuiTabContentText { + display: inline-block; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; +} + +.cuiEmptyButton { + @include mixins.cuiEmptyButton; + background: tokens.$clickTabsFileTabsColorCloseButtonBackgroundDefault; + + &:hover { + background: tokens.$clickTabsFileTabsColorCloseButtonBackgroundHover; + } +} diff --git a/src/components/FileTabs/FileTabs.stories.tsx b/src/components/FileTabs/FileTabs.stories.tsx index a5d51d92d..d20add7a8 100644 --- a/src/components/FileTabs/FileTabs.stories.tsx +++ b/src/components/FileTabs/FileTabs.stories.tsx @@ -83,3 +83,252 @@ export const Playground: Story = { status: "neutral", }, }; + +export const Variations: Story = { + render: () => ( +
+
+

Default File Tabs

+
+ + null} + onClose={() => {}} + onSelect={() => {}} + selectedIndex={0} + > + + + + +
+
+ +
+

Selected Tab (Second Tab)

+
+ + null} + onClose={() => {}} + onSelect={() => {}} + selectedIndex={1} + > + + + + +
+
+ +
+

Status Types

+
+
+ + null} + onClose={() => {}} + onSelect={() => {}} + selectedIndex={0} + > + + + + +
+
+ + null} + onClose={() => {}} + onSelect={() => {}} + selectedIndex={0} + > + + + + +
+
+
+ +
+

Preview Mode

+
+ + null} + onClose={() => {}} + onSelect={() => {}} + selectedIndex={0} + > + + + + +
+
+ +
+

Many Tabs

+
+ + null} + onClose={() => {}} + onSelect={() => {}} + selectedIndex={3} + > + + + + + + + +
+
+ +
+

Fixed Tab Element Variations

+
+
+ + + +
+
+ Home + + Code + + Tables +
+
+ + + Preview Mode + +
+
+
+
+ ), + parameters: { + controls: { disable: true }, + actions: { disable: true }, + pseudo: { + hover: [".cuiTabElement"], + focus: [".cuiTabElement"], + }, + chromatic: { + delay: 300, + }, + }, +}; diff --git a/src/components/FileTabs/FileTabs.tsx b/src/components/FileTabs/FileTabs.tsx index 508747f70..7ff0d457a 100644 --- a/src/components/FileTabs/FileTabs.tsx +++ b/src/components/FileTabs/FileTabs.tsx @@ -1,5 +1,7 @@ +"use client"; + import { - HTMLAttributes, + ComponentPropsWithoutRef, createContext, useContext, ReactElement, @@ -11,9 +13,9 @@ import { WheelEvent, useRef, } from "react"; -import { styled } from "styled-components"; +import clsx from "clsx"; import { Icon, IconButton } from "@/components"; -import { IconName } from "../Icon/types"; +import { IconName } from "@/components/Icon/types"; import { ItemInterface, ReactSortable, @@ -21,6 +23,7 @@ import { Sortable, Store, } from "react-sortablejs"; +import styles from "./FileTabs.module.scss"; export type FileTabStatusType = | "default" @@ -30,30 +33,6 @@ export type FileTabStatusType = | "warning" | "info"; -const TabsContainer = styled.div<{ $count: number }>` - display: flex; - position: relative; - overflow: auto; - overscroll-behavior: none; - scrollbar-width: 0; - max-width: ${({ $count }) => `${$count * 200}px`}; - &::-webkit-scrollbar { - height: 0; - } -`; -const TabsSortableContainer = styled.div` - display: flex; - & > div { - height: 100%; - outline: none; - min-width: 100px; - width: clamp(100px, 100%, 200px); - &.sortable-ghost { - opacity: 0; - } - } -`; - interface ContextProps { selectedIndex?: number; onClose: (index: number) => void; @@ -64,7 +43,7 @@ export const TabContext = createContext({ onClose: () => null, }); -export interface FileTabProps extends Omit, "children"> { +export interface FileTabProps extends Omit, "children"> { /** Callback when the tab is closed */ onClose?: () => void; /** Index of the tab in the list */ @@ -163,7 +142,7 @@ export const FileTabs = ({ }; return ( - - ))} - - + + ); }; -const TabElement = styled.div<{ - $active: boolean; - $preview?: boolean; - $dismissable: boolean; - $fixedTabElement?: boolean; -}>` - display: grid; - justify-content: flex-start; - align-items: center; - outline: none; - max-width: 100%; - max-width: -webkit-fill-available; - max-width: fill-available; - max-width: stretch; - border: none; - cursor: pointer; - height: 100%; - max-height: 100%; - box-sizing: border-box; - ${({ theme, $active, $preview, $dismissable, $fixedTabElement }) => ` - width:${$fixedTabElement ? "auto" : "100%"}; - grid-template-columns: 1fr ${ - $dismissable ? theme.click.tabs.fileTabs.icon.size.width : "" - }; - padding: ${theme.click.tabs.fileTabs.space.y} ${theme.click.tabs.fileTabs.space.x}; - gap: ${theme.click.tabs.fileTabs.space.gap}; - border-radius: ${theme.click.tabs.fileTabs.radii.all}; - border-right: 1px solid ${theme.click.tabs.fileTabs.color.stroke.default}; - background: ${theme.click.tabs.fileTabs.color.background.default}; - color: ${theme.click.tabs.fileTabs.color.text.default}; - font: ${theme.click.tabs.fileTabs.typography.label.default}; - svg, - [data-indicator] { - height: ${theme.click.tabs.fileTabs.icon.size.height}; - width: ${theme.click.tabs.fileTabs.icon.size.width}; - } - ${ - $active - ? ` - background: ${theme.click.tabs.fileTabs.color.background.active}; - color: ${theme.click.tabs.fileTabs.color.text.active}; - font: ${theme.click.tabs.fileTabs.typography.label.active}; - border-right: 1px solid ${theme.click.tabs.fileTabs.color.stroke.active}; - ` - : ` - &:hover { - background: ${theme.click.tabs.fileTabs.color.background.hover}; - color: ${theme.click.tabs.fileTabs.color.text.hover}; - font: ${theme.click.tabs.fileTabs.typography.label.hover}; - border-right: 1px solid ${theme.click.tabs.fileTabs.color.stroke.hover}; - } - ` - } - ${$preview === true ? "font-style: italic;" : ""} - `} - [data-type="close"] { - display: none; - } - [data-indicator] { - display: block; - } - &:hover { - [data-type="close"] { - display: block; - } - [data-indicator] { - display: none; - } - } -`; - -const Indicator = styled.div<{ $status: FileTabStatusType }>` - position: relative; - &::after { - position: absolute; - left: 0.25rem; - top: 0.25rem; - content: ""; - width: 0.5rem; - height: 0.5rem; - ${({ theme, $status }) => ` - background: ${ - $status === "default" ? "transparent" : theme.click.alert.color.text[$status] - }; - border-radius: 50%; - `} - } -`; - -const TabContent = styled.div` - display: flex; - justify-content: flex-start; - align-items: center; - flex-wrap: nowrap; - overflow: hidden; - gap: ${({ theme }) => theme.click.tabs.fileTabs.space.gap}; -`; - -const TabContentText = styled.span` - display: inline-block; - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; -`; - -const EmptyButton = styled.button` - padding: 0; - ${({ theme }) => theme.click.tabs.fileTabs.color.closeButton.background.default}; - &:hover { - background: ${({ theme }) => - theme.click.tabs.fileTabs.color.closeButton.background.hover}; - } -`; - const Tab = ({ text, index, @@ -333,6 +203,7 @@ const Tab = ({ status = "default", testId, preview, + className, ...props }: FileTabProps) => { const { selectedIndex, onClose: onCloseProp } = useSelect(); @@ -354,31 +225,37 @@ const Tab = ({ }; return ( - - +
{typeof icon === "string" ? : icon} - {text} - - {text} +
+ - -
+ ); }; @@ -386,7 +263,7 @@ Tab.displayName = "FileTab"; FileTabs.Tab = Tab; -interface FileTabElementProps extends HTMLAttributes { +interface FileTabElementProps extends ComponentPropsWithoutRef<"div"> { icon?: IconName | ReactNode; active?: boolean; preview?: boolean; @@ -396,18 +273,24 @@ export const FileTabElement = ({ children, active = false, preview, + className, ...props }: FileTabElementProps) => { return ( - {typeof icon === "string" ? : icon} - {children && {children}} - + {children && {children}} + ); }; diff --git a/src/components/FileUpload/FileMultiUpload.module.scss b/src/components/FileUpload/FileMultiUpload.module.scss new file mode 100644 index 000000000..779070ebc --- /dev/null +++ b/src/components/FileUpload/FileMultiUpload.module.scss @@ -0,0 +1,36 @@ +@use "cui-mixins" as mixins; +@use "cui-variants" as variants; + +/* Component-specific styles only - common styles imported in TSX */ +.cuiUploadArea { + flex-direction: column; + justify-content: center; + cursor: pointer; +} + +.cuiFileUploadTitle { + @include variants.variant('cuiNotSupported') { + color: tokens.$clickFileUploadColorTitleError; + } +} + +.cuiUploadText { + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + width: 100%; +} + +.cuiFilesList { + display: flex; + flex-direction: column; + gap: tokens.$clickFileUploadSmSpaceGap; + width: 100%; + margin-top: tokens.$clickFileUploadMdSpaceGap; +} + +.cuiFileItem { + flex-direction: row; + justify-content: space-between; +} diff --git a/src/components/FileUpload/FileMultiUpload.stories.tsx b/src/components/FileUpload/FileMultiUpload.stories.tsx index bbb4a72b2..a8477272b 100644 --- a/src/components/FileUpload/FileMultiUpload.stories.tsx +++ b/src/components/FileUpload/FileMultiUpload.stories.tsx @@ -22,181 +22,6 @@ export default meta; type Story = StoryObj; -export const EmptyState: Story = { - args: { - title: "Upload multiple files", - supportedFileTypes: [".txt", ".csv", ".json", ".sql"], - files: [], - onFileSelect: (file: File) => console.log("File selected:", file.name), - onFileRetry: (fileId: string) => console.log("Retry file:", fileId), - onFileRemove: (fileId: string) => console.log("Remove file:", fileId), - }, - parameters: { - docs: { - description: { - story: "Shows the `FileMultiUpload` component with no files uploaded", - }, - }, - }, -}; - -export const WithUploadingFiles: Story = { - args: { - title: "Upload multiple files", - supportedFileTypes: [".txt", ".csv", ".json", ".sql"], - files: [ - { - id: "1", - name: "document1.txt", - size: 1024, - status: "uploading" as const, - progress: 45, - }, - { - id: "2", - name: "spreadsheet.csv", - size: 2048, - status: "uploading" as const, - progress: 75, - }, - ], - onFileSelect: (file: File) => console.log("File selected:", file.name), - onFileRetry: (fileId: string) => console.log("Retry file:", fileId), - onFileRemove: (fileId: string) => console.log("Remove file:", fileId), - }, - parameters: { - docs: { - description: { - story: "Shows the `FileMultiUpload` component with files currently uploading", - }, - }, - }, -}; - -export const WithSuccessFiles: Story = { - args: { - title: "Upload multiple files", - supportedFileTypes: [".txt", ".csv", ".json", ".sql"], - files: [ - { - id: "1", - name: "document1.txt", - size: 1024, - status: "success" as const, - progress: 100, - }, - { - id: "2", - name: "spreadsheet.csv", - size: 2048, - status: "success" as const, - progress: 100, - }, - { - id: "3", - name: "config.json", - size: 512, - status: "success" as const, - progress: 100, - }, - ], - onFileSelect: (file: File) => console.log("File selected:", file.name), - onFileRetry: (fileId: string) => console.log("Retry file:", fileId), - onFileRemove: (fileId: string) => console.log("Remove file:", fileId), - }, - parameters: { - docs: { - description: { - story: "Shows the `FileMultiUpload` component with successfully uploaded files", - }, - }, - }, -}; - -export const WithErrorFiles: Story = { - args: { - title: "Upload multiple files", - supportedFileTypes: [".txt", ".csv", ".json", ".sql"], - files: [ - { - id: "1", - name: "document1.txt", - size: 1024, - status: "error" as const, - progress: 0, - errorMessage: "Upload failed", - }, - { - id: "2", - name: "large-file.csv", - size: 5242880, - status: "error" as const, - progress: 0, - errorMessage: "File too large", - }, - ], - onFileSelect: (file: File) => console.log("File selected:", file.name), - onFileRetry: (fileId: string) => console.log("Retry file:", fileId), - onFileRemove: (fileId: string) => console.log("Remove file:", fileId), - }, - parameters: { - docs: { - description: { - story: "Shows the `FileMultiUpload` component with files that failed to upload", - }, - }, - }, -}; - -export const MixedStates: Story = { - args: { - title: "Upload multiple files", - supportedFileTypes: [".txt", ".csv", ".json", ".sql"], - files: [ - { - id: "1", - name: "document1.txt", - size: 1024, - status: "success" as const, - progress: 100, - }, - { - id: "2", - name: "uploading-file.csv", - size: 2048, - status: "uploading" as const, - progress: 65, - }, - { - id: "3", - name: "failed-file.json", - size: 512, - status: "error" as const, - progress: 0, - errorMessage: "Network error", - }, - { - id: "4", - name: "another-success.sql", - size: 1536, - status: "success" as const, - progress: 100, - }, - ], - onFileSelect: (file: File) => console.log("File selected:", file.name), - onFileRetry: (fileId: string) => console.log("Retry file:", fileId), - onFileRemove: (fileId: string) => console.log("Remove file:", fileId), - }, - parameters: { - docs: { - description: { - story: - "Shows the `FileMultiUpload` component with files in various states (success, uploading, error)", - }, - }, - }, -}; - // Interactive example that demonstrates state management export const Interactive: StoryFn = () => { const [files, setFiles] = useState([]); @@ -278,3 +103,207 @@ Interactive.parameters = { }, }, }; + +export const Variations: Story = { + render: () => ( +
+
+

Empty State

+
+ console.log("File selected:", file)} + onFileRetry={fileId => console.log("Retry file:", fileId)} + onFileRemove={fileId => console.log("Remove file:", fileId)} + /> +
+
+ +
+

With Uploading Files

+
+ console.log("File selected:", file)} + onFileRetry={fileId => console.log("Retry file:", fileId)} + onFileRemove={fileId => console.log("Remove file:", fileId)} + /> +
+
+ +
+

With Success Files

+
+ console.log("File selected:", file)} + onFileRetry={fileId => console.log("Retry file:", fileId)} + onFileRemove={fileId => console.log("Remove file:", fileId)} + /> +
+
+ +
+

With Error Files

+
+ console.log("File selected:", file)} + onFileRetry={fileId => console.log("Retry file:", fileId)} + onFileRemove={fileId => console.log("Remove file:", fileId)} + /> +
+
+ +
+

Mixed States

+
+ console.log("File selected:", file)} + onFileRetry={fileId => console.log("Retry file:", fileId)} + onFileRemove={fileId => console.log("Remove file:", fileId)} + /> +
+
+ +
+

Supported File Types Variations

+
+
+

+ Text Files Only +

+ console.log("File selected:", file)} + onFileRetry={fileId => console.log("Retry file:", fileId)} + onFileRemove={fileId => console.log("Remove file:", fileId)} + /> +
+
+

+ Data Files Only +

+ console.log("File selected:", file)} + onFileRetry={fileId => console.log("Retry file:", fileId)} + onFileRemove={fileId => console.log("Remove file:", fileId)} + /> +
+
+
+
+ ), + parameters: { + controls: { disable: true }, + actions: { disable: true }, + pseudo: { + hover: [".cuiUploadArea", ".cuiFileItem"], + }, + chromatic: { + delay: 300, + }, + }, +}; diff --git a/src/components/FileUpload/FileMultiUpload.tsx b/src/components/FileUpload/FileMultiUpload.tsx index 49423c721..ad597a39e 100644 --- a/src/components/FileUpload/FileMultiUpload.tsx +++ b/src/components/FileUpload/FileMultiUpload.tsx @@ -1,170 +1,33 @@ import React, { useEffect } from "react"; -import styled from "styled-components"; -import { css } from "styled-components"; import { useState, useRef, useCallback } from "react"; +import clsx from "clsx"; import { truncateFilename } from "@/utils/truncate.ts"; import { Text } from "@/components/Typography/Text/Text"; import { Title } from "@/components/Typography/Title/Title"; import { Button, Icon, IconButton, ProgressBar } from "@/components"; +import styles from "./FileMultiUpload.module.scss"; +import commonStyles from "./FileUploadCommon.module.scss"; export interface FileUploadItem { - /** Unique identifier for the file */ id: string; - /** Name of the file */ name: string; - /** Size of the file in bytes */ size: number; - /** Current upload status */ status: "uploading" | "success" | "error"; - /** Upload progress (0-100) */ progress: number; - /** Error message when status is "error" */ errorMessage?: string; } interface FileMultiUploadProps { - /** The title text displayed in the upload area */ title: string; - /** Array of supported file extensions (e.g., [".txt", ".csv"]) */ supportedFileTypes?: string[]; - /** Array of files with their upload status */ files: FileUploadItem[]; - /** Callback when a file is selected */ onFileSelect?: (file: File) => void; - /** Callback when retry is clicked for a file */ onFileRetry?: (fileId: string) => void; - /** Callback when a file is removed */ onFileRemove?: (fileId: string) => void; - /** Callback when file selection fails */ onFileFailure?: () => void; } -const UploadArea = styled.div<{ - $isDragging: boolean; -}>` - background-color: ${({ theme }) => theme.click.fileUpload.color.background.default}; - border: ${({ theme }) => `1px solid ${theme.click.fileUpload.color.stroke.default}`}; - border-radius: ${({ theme }) => theme.click.fileUpload.md.radii.all}; - padding: ${({ theme }) => - `${theme.click.fileUpload.md.space.y} ${theme.click.fileUpload.md.space.x}`}; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: ${({ theme }) => theme.click.fileUpload.md.space.gap}; - cursor: pointer; - transition: ${({ theme }) => theme.click.fileUpload.transitions.all}; - border-style: dashed; - border-color: ${({ theme }) => theme.click.fileUpload.color.stroke.default}; - - ${props => - props.$isDragging && - css` - background-color: ${({ theme }) => theme.click.fileUpload.color.background.active}; - border-color: ${({ theme }) => theme.click.fileUpload.color.stroke.active}; - `} -`; - -const FileUploadTitle = styled(Title)<{ $isNotSupported: boolean }>` - font: ${({ theme }) => theme.click.fileUpload.typography.title.default}; - color: ${({ theme, $isNotSupported }) => - $isNotSupported - ? theme.click.fileUpload.color.title.error - : theme.click.fileUpload.color.title.default}; -`; - -const FileName = styled(Text)` - font: ${({ theme }) => theme.click.fileUpload.typography.description.default}; - color: ${({ theme }) => theme.click.fileUpload.color.title.default}; -`; - -const FileUploadDescription = styled(Text)<{ $isError?: boolean }>` - font: ${({ theme }) => theme.click.fileUpload.typography.description.default}; - color: ${({ theme, $isError }) => - $isError - ? theme.click.fileUpload.color.title.error - : theme.click.fileUpload.color.description.default}; -`; - -const UploadIcon = styled(Icon)` - svg { - width: ${({ theme }) => theme.click.fileUpload.md.icon.size.width}; - height: ${({ theme }) => theme.click.fileUpload.md.icon.size.height}; - color: ${({ theme }) => theme.click.fileUpload.md.color.icon.default}; - } -`; - -const UploadText = styled.div` - text-align: center; - display: flex; - flex-direction: column; - align-items: center; - width: 100%; -`; - -const FilesList = styled.div` - display: flex; - flex-direction: column; - gap: ${({ theme }) => theme.click.fileUpload.sm.space.gap}; - width: 100%; - margin-top: ${({ theme }) => theme.click.fileUpload.md.space.gap}; -`; - -const FileItem = styled.div<{ $isError?: boolean }>` - background-color: ${({ theme }) => theme.click.fileUpload.color.background.default}; - border: ${({ theme }) => `1px solid ${theme.click.fileUpload.color.stroke.default}`}; - border-radius: ${({ theme }) => theme.click.fileUpload.sm.radii.all}; - padding: ${({ theme }) => - `${theme.click.fileUpload.sm.space.y} ${theme.click.fileUpload.sm.space.x}`}; - display: flex; - flex-direction: row; - align-items: center; - justify-content: space-between; - gap: ${({ theme }) => theme.click.fileUpload.sm.space.gap}; - - ${props => - props.$isError && - css` - background-color: ${({ theme }) => theme.click.fileUpload.color.background.error}; - border-color: transparent; - `} -`; - -const DocumentIcon = styled(Icon)` - svg { - width: ${({ theme }) => theme.click.fileUpload.sm.icon.size.width}; - height: ${({ theme }) => theme.click.fileUpload.sm.icon.size.height}; - color: ${({ theme }) => theme.click.fileUpload.sm.color.icon.default}; - } -`; - -const FileDetails = styled.div` - display: flex; - gap: ${({ theme }) => theme.click.fileUpload.md.space.gap}; - border: none; -`; - -const FileActions = styled.div` - display: flex; - align-items: center; - margin-left: auto; - gap: 0; -`; - -const FileContentContainer = styled.div` - flex: 1; - display: flex; - flex-direction: column; - justify-content: center; - min-height: 24px; -`; - -const ProgressBarWrapper = styled.div` - margin-top: ${({ theme }) => theme.click.fileUpload.md.space.gap}; - margin-bottom: 9px; -`; - const formatFileSize = (sizeInBytes: number): string => { if (sizeInBytes < 1024) { return `${sizeInBytes.toFixed(1)} B`; @@ -332,35 +195,52 @@ export const FileMultiUpload = ({ return ( <> - - - + +
{!isSupported ? ( - Unsupported file type - + ) : ( - {title} - + )} - + Files supported: {supportedFileTypes.join(", ")} - - + +
-
+ {files.length > 0 && ( - +
{files.map(file => ( - - - - - {truncateFilename(file.name)} + +
+
+ {truncateFilename(file.name)} {file.status === "uploading" && ( - {file.progress}% + + {file.progress}% + )} {file.status === "error" && ( - + {file.errorMessage || "Upload failed"} - + )} {file.status === "success" && ( )} - +
+ {file.status === "uploading" && ( - +
- +
)} {(file.status === "success" || file.status === "error") && ( - + {formatFileSize(file.size)} - + )} - - +
+ +
{file.status === "error" && ( handleRemoveFile(file.id)} /> - - +
+
))} -
+ )} ; -export const SmallSize: Story = { - args: { - title: "Upload file", - supportedFileTypes: [".txt", ".csv"], - size: "sm", - progress: 75, - showProgress: false, - showSuccess: false, - onRetry: () => console.log("File retried"), - onFileFailure: () => console.log("File failed"), - onFileClose: () => console.log("File dismissed"), - }, - parameters: { - docs: { - description: { - story: "Shows the `FileUpload` component in small size variant", - }, - }, - }, -}; +export const Variations: Story = { + render: () => ( +
+
+

Sizes

+
+
+

Small

+ console.log("File selected:", file)} + /> +
+
+

Medium

+ console.log("File selected:", file)} + /> +
+
+
-export const MediumSize: Story = { - args: { - title: "Upload file", - supportedFileTypes: [".txt", ".csv", ".json", ".sql"], - progress: 65, - size: "md", - onRetry: () => console.log("File retried"), - onFileFailure: () => console.log("File failed"), - onFileClose: () => console.log("File dismissed"), - }, - parameters: { - docs: { - description: { - story: "Shows the `FileUpload` component in medium size variant", - }, - }, - }, -}; +
+

File Upload States (Simulated)

+
+
+

Empty State

+ console.log("File selected:", file)} + /> +
+
+
-export const RestrictedFileTypes: Story = { - args: { - title: "Upload SQL files only", - supportedFileTypes: [".sql"], - size: "md", - onRetry: () => console.log("File retried"), - onFileFailure: () => console.log("File failed - unsupported type"), - onFileClose: () => console.log("File dismissed"), - }, +
+

Supported File Types

+
+
+

Text Files

+ console.log("File selected:", file)} + /> +
+
+

Data Files

+ console.log("File selected:", file)} + /> +
+
+

+ SQL Files Only +

+ console.log("File selected:", file)} + /> +
+
+
+ +
+

Custom Titles

+
+
+

+ Custom Title 1 +

+ console.log("File selected:", file)} + /> +
+
+

+ Custom Title 2 +

+ console.log("File selected:", file)} + /> +
+
+
+
+ ), parameters: { - docs: { - description: { - story: - "Shows the `FileUpload` component with restricted file types. Try dropping or selecting a non-SQL file to see the 'Unsupported file type' message.", - }, + controls: { disable: true }, + actions: { disable: true }, + pseudo: { + hover: [".cuiUploadArea"], + }, + chromatic: { + delay: 300, }, }, }; diff --git a/src/components/FileUpload/FileUpload.tsx b/src/components/FileUpload/FileUpload.tsx index fb547f869..ae14a67aa 100644 --- a/src/components/FileUpload/FileUpload.tsx +++ b/src/components/FileUpload/FileUpload.tsx @@ -1,12 +1,15 @@ +"use client"; + import React, { useEffect } from "react"; -import styled from "styled-components"; -import { css } from "styled-components"; import { useState, useRef, useCallback } from "react"; +import clsx from "clsx"; import { truncateFilename } from "@/utils/truncate.ts"; import { Text } from "@/components/Typography/Text/Text"; import { Title } from "@/components/Typography/Title/Title"; import { Button, Icon, IconButton, ProgressBar } from "@/components"; +import styles from "./FileUpload.module.scss"; +import commonStyles from "./FileUploadCommon.module.scss"; interface FileInfo { name: string; @@ -14,173 +17,19 @@ interface FileInfo { } interface FileUploadProps { - /** The title text displayed in the upload area */ title: string; - /** Array of supported file extensions (e.g., [".txt", ".csv"]) */ supportedFileTypes?: string[]; - /** The size variant of the upload component */ size?: "sm" | "md"; - /** Current upload progress (0-100) */ progress?: number; - /** Whether to show success state */ showSuccess?: boolean; - /** Whether to show the progress bar */ showProgress?: boolean; - /** Message to display when upload fails */ failureMessage?: string; - /** Callback when retry button is clicked */ onRetry?: () => void; - /** Callback when a file is selected */ onFileSelect?: (file: File) => void; - /** Callback when file selection fails */ onFileFailure?: () => void; - /** Callback when the file is removed/closed */ onFileClose?: () => void; } -const UploadArea = styled.div<{ - $isDragging: boolean; - $size: "sm" | "md"; - $hasFile: boolean; - $isError?: boolean; -}>` - background-color: ${({ theme }) => theme.click.fileUpload.color.background.default}; - border: ${({ theme }) => `1px solid ${theme.click.fileUpload.color.stroke.default}`}; - border-radius: ${({ theme, $hasFile }) => - $hasFile - ? `${theme.click.fileUpload.sm.radii.all}` - : `${theme.click.fileUpload.md.radii.all}`}; - padding: ${({ theme, $hasFile, $size }) => - $hasFile || $size === "sm" - ? `${theme.click.fileUpload.sm.space.y} ${theme.click.fileUpload.sm.space.x}` - : `${theme.click.fileUpload.md.space.y} ${theme.click.fileUpload.md.space.x}`}; - min-height: ${({ theme, $size }) => - $size === "sm" - ? `calc(${theme.click.fileUpload.sm.space.y} * 2 + ${theme.sizes[6]})` - : "auto"}; - display: flex; - flex-direction: ${props => - props.$hasFile ? "row" : props.$size === "sm" ? "row" : "column"}; - align-items: center; - justify-content: ${props => - props.$hasFile ? "space-between" : props.$size === "sm" ? "space-between" : "center"}; - gap: ${({ theme, $size }) => - $size === "sm" - ? theme.click.fileUpload.sm.space.gap - : theme.click.fileUpload.md.space.gap}; - cursor: ${props => (props.$hasFile ? "default" : "pointer")}; - transition: ${({ theme }) => theme.click.fileUpload.transitions.all}; - - ${props => - !props.$hasFile && - css` - border-style: dashed; - border-color: ${({ theme }) => theme.click.fileUpload.color.stroke.default}; - - ${props.$isDragging && - css` - background-color: ${({ theme }) => - theme.click.fileUpload.color.background.active}; - border-color: ${({ theme }) => theme.click.fileUpload.color.stroke.active}; - `} - `} - - ${props => - props.$isError && - css` - background-color: ${({ theme }) => theme.click.fileUpload.color.background.error}; - border-color: transparent; - `} -`; - -const FileUploadTitle = styled(Title)<{ $isNotSupported: boolean }>` - font: ${({ theme }) => theme.click.fileUpload.typography.title.default}; - color: ${({ theme, $isNotSupported }) => - $isNotSupported - ? theme.click.fileUpload.color.title.error - : theme.click.fileUpload.color.title.default}; -`; - -const FileName = styled(Text)` - font: ${({ theme }) => theme.click.fileUpload.typography.description.default}; - color: ${({ theme }) => theme.click.fileUpload.color.title.default}; -`; - -const FileUploadDescription = styled(Text)<{ $isError?: boolean }>` - font: ${({ theme }) => theme.click.fileUpload.typography.description.default}; - color: ${({ theme, $isError }) => - $isError - ? theme.click.fileUpload.color.title.error - : theme.click.fileUpload.color.description.default}; -`; - -const DocumentIcon = styled(Icon)` - svg { - width: ${({ theme }) => theme.click.fileUpload.md.icon.size.width}; - height: ${({ theme }) => theme.click.fileUpload.md.icon.size.height}; - color: ${({ theme }) => theme.click.fileUpload.md.color.icon.default}; - } -`; - -const UploadIcon = styled(Icon)` - svg { - width: ${({ theme }) => theme.click.fileUpload.md.icon.size.width}; - height: ${({ theme }) => theme.click.fileUpload.md.icon.size.height}; - color: ${({ theme }) => theme.click.fileUpload.md.color.icon.default}; - } -`; - -const UploadText = styled.div<{ $size: "sm" | "md"; $hasFile: boolean }>` - text-align: ${props => (props.$hasFile || props.$size === "sm" ? "left" : "center")}; - ${props => - (props.$hasFile || props.$size === "sm") && - css` - flex: 1; - `} - - ${props => - !props.$hasFile && - props.$size === "md" && - css` - display: flex; - flex-direction: column; - align-items: center; - width: 100%; - `} -`; - -const FileInfo = styled.div` - display: flex; - flex-direction: row; - gap: ${({ theme }) => theme.click.fileUpload.hasFile.header.space.gap}; -`; - -const FileDetails = styled.div` - display: flex; - gap: ${({ theme }) => theme.click.fileUpload.md.space.gap}; - border: none; -`; - -const FileActions = styled.div` - display: flex; - align-items: center; - margin-left: auto; - gap: 0; -`; - -const FileContentContainer = styled.div<{ $size: "sm" | "md" }>` - flex: 1; - display: flex; - flex-direction: column; - justify-content: center; - min-height: ${({ $size }) => ($size === "sm" ? "24px" : "auto")}; -`; - -const ProgressBarWrapper = styled.div` - margin-top: ${({ theme }) => theme.click.fileUpload.md.space.gap}; - margin-bottom: 9px; -`; - const formatFileSize = (sizeInBytes: number): string => { if (sizeInBytes < 1024) { return `${sizeInBytes.toFixed(1)} B`; @@ -248,7 +97,6 @@ export const FileUpload = ({ dragCounterRef.current -= 1; - // Only set to false when left the container if (dragCounterRef.current <= 0) { setIsDragging(false); dragCounterRef.current = 0; @@ -260,7 +108,6 @@ export const FileUpload = ({ e.stopPropagation(); }, []); - // Reset state when drag ends anywhere in the document useEffect(() => { const handleDragEnd = () => { setIsDragging(false); @@ -356,11 +203,20 @@ export const FileUpload = ({ return ( <> - {!file ? ( <> - - +
{isNotSupported ? ( - Unsupported file type - + ) : ( - {title} - + )} - + Files supported: {supportedFileTypes.join(", ")} - - + +
+ + + + + + + + + + Inline type content with compact spacing. + + + + + + + + + + + +
+

Alignment Variants

+
+ + + + + + Left-aligned flyout content. + + + + + + + + + + + Right-aligned flyout content. + + + + +
+
+ +
+

Header Variants

+
+ + + + + + Content here. + + + + + + + + + + + Content here. + + + + + + + + + + + Content without separator. + + + + +
+
+ +
+

Body Alignment

+
+ + + + + + Body with default alignment. + + + + + + + + + + + Body with top alignment. + + + + +
+
+ + ), + parameters: { + controls: { disable: true }, + actions: { disable: true }, + pseudo: { + hover: [".cuiFlyoutContent", ".cuiFlyoutHeaderContainer"], + focus: [".cuiFlyoutContent", ".cuiFlyoutHeaderContainer"], + focusVisible: [".cuiFlyoutContent", ".cuiFlyoutHeaderContainer"], + }, + }, +}; diff --git a/src/components/Flyout/Flyout.tsx b/src/components/Flyout/Flyout.tsx index 07b00427d..9d192d422 100644 --- a/src/components/Flyout/Flyout.tsx +++ b/src/components/Flyout/Flyout.tsx @@ -1,3 +1,5 @@ +"use client"; + import { ReactNode } from "react"; import { Dialog, @@ -12,6 +14,7 @@ import { DialogTriggerProps, DialogContentProps as RadixDialogContentProps, } from "@radix-ui/react-dialog"; +import clsx from "clsx"; import { Button, ButtonProps, @@ -22,9 +25,8 @@ import { Separator, Spacer, } from "@/components"; -import { styled } from "styled-components"; -import { CrossButton } from "../commonElement"; -import { keyframes } from "styled-components"; +import { CrossButton } from "@/components/commonElement"; +import styles from "./Flyout.module.scss"; export type FlyoutProps = DialogProps; @@ -74,84 +76,12 @@ export interface DialogContentProps extends RadixDialogContentProps { align?: DialogContentAlignmentType; } -const animationWidth = () => - keyframes({ - from: { width: 0 }, - to: { width: "fit-content" }, - }); - -const FlyoutContent = styled(DialogContent)<{ - $size?: FlyoutSizeType; - $type?: FlyoutType; - $strategy: Strategy; - $width?: string; - $align: DialogContentAlignmentType; -}>` - display: flex; - flex-direction: column; - align-items: center; - overflow: hidden; - top: 0; - bottom: 0; - width: fit-content; - --flyout-width: ${({ theme, $size = "default", $width }) => - $width || theme.click.flyout.size[$size].width}; - animation: ${animationWidth} 500ms cubic-bezier(0.16, 1, 0.3, 1) forwards; - ${({ theme, $strategy, $type = "default", $align }) => ` - ${$align === "start" ? "left" : "right"}: 0; - max-width: 100%; - position: ${$strategy}; - height: ${$strategy === "relative" ? "100%" : "auto"}; - padding: 0 ${theme.click.flyout.space[$type].x} - gap: ${theme.click.flyout.space[$type].gap}; - box-shadow: ${ - $align === "start" - ? theme.click.flyout.shadow.reverse - : theme.click.flyout.shadow.default - }; - border-${$align === "start" ? "right" : "left"}: 1px solid ${ - theme.click.flyout.color.stroke.default - }; - background: ${theme.click.flyout.color.background.default}; - - @media (max-width: 1024px) { - ${ - $strategy === "relative" - ? ` - position: absolute !important;` - : "" - } - overflow: hidden; - transform: ${ - $align === "start" - ? "translateX(calc(50px - 100%))" - : "translateX(calc(100% - 50px))" - }; - transition: 0.3s ease-in-out; - &:hover, - &.active, - &:focus-within { - transform: translateX(0); - ${$align === "start" ? "right" : "left"}: auto; - } - } - `} -`; -const FlyoutContainer = styled.div` - display: flex; - gap: 0; - width: var(--flyout-width); - max-width: 100%; - flex-flow: column nowrap; - gap: inherit; -`; - const Content = ({ showOverlay = false, children, container, strategy = "relative", - size, + size = "default", type = "default", closeOnInteractOutside = false, width, @@ -159,13 +89,29 @@ const Content = ({ onInteractOutside, ...props }: DialogContentProps) => { + const customWidthStyle = width + ? ({ "--flyout-width": width } as React.CSSProperties) + : {}; + return ( - {showOverlay && } - } + { if (!closeOnInteractOutside) { e.preventDefault(); @@ -174,31 +120,16 @@ const Content = ({ onInteractOutside(e); } }} - $width={width} - $align={align} {...props} > {children} - + ); }; Content.displayName = "Flyout.Content"; Flyout.Content = Content; -const FlyoutElement = styled(Container)<{ - $type?: FlyoutType; -}>` - max-width: 100%; - max-width: -webkit-fill-available; - max-width: fill-available; - max-width: stretch; - ${({ theme, $type = "default" }) => ` - gap: ${theme.click.flyout.space[$type].gap}; - padding: 0 ${theme.click.flyout.space[$type].content.x}; - `} -`; - interface ElementProps extends Omit< ContainerProps, "component" | "padding" | "gap" | "orientation" @@ -206,12 +137,15 @@ interface ElementProps extends Omit< type?: FlyoutType; } -const Element = ({ type, ...props }: ElementProps) => ( - ( + ); @@ -257,42 +191,10 @@ interface ChildrenHeaderProps extends Omit< export type FlyoutHeaderProps = TitleHeaderProps | ChildrenHeaderProps; -const FlyoutHeaderContainer = styled(Container)<{ - $type?: FlyoutType; -}>` - ${({ theme, $type = "default" }) => ` - row-gap: ${theme.click.flyout.space[$type].content["row-gap"]}; - column-gap: ${theme.click.flyout.space[$type].content["column-gap"]}; - padding: ${theme.click.flyout.space[$type].y} ${theme.click.flyout.space[$type].y} 0 ${theme.click.flyout.space[$type].y} ; - `} -`; - -const FlyoutTitle = styled(DialogTitle)<{ - $type?: FlyoutType; -}>` - ${({ theme, $type = "default" }) => ` - color: ${theme.click.flyout.color.title.default}; - font: ${theme.click.flyout.typography[$type].title.default}; - margin: 0; - padding: 0; - `} -`; - -const FlyoutDescription = styled(DialogDescription)<{ - $type?: FlyoutType; -}>` - ${({ theme, $type = "default" }) => ` - color: ${theme.click.flyout.color.description.default}; - font: ${theme.click.flyout.typography[$type].description.default}; - margin: 0; - padding: 0; - `} -`; - const Header = ({ title, description, - type, + type = "default", children, showClose = true, showSeparator = true, @@ -300,9 +202,12 @@ const Header = ({ }: FlyoutHeaderProps) => { if (children) { return ( - - + )} - + {showSeparator && ( )} - + ); } return ( - - + - {title} + + {title} + {description && ( - {description} + + {description} + )} {showClose && ( @@ -371,36 +293,34 @@ const Header = ({ )} - + {showSeparator && ( )} - + ); }; Header.displayName = "Flyout.Header"; Flyout.Header = Header; type FlyoutAlign = "default" | "top"; -const FlyoutBody = styled(Container)<{ $align?: FlyoutAlign }>` - width: var(--flyout-width); - max-width: 100%; - margin-top: ${({ $align = "default" }) => ($align === "top" ? "-1rem" : 0)}; -`; interface BodyProps extends ContainerProps { align?: FlyoutAlign; } -const Body = ({ align, ...props }: BodyProps) => ( - ( + ); @@ -415,16 +335,6 @@ export interface FlyoutFooterProps extends Omit< type?: FlyoutType; } -const FlyoutFooter = styled(Container)<{ - type?: FlyoutType; -}>` - ${({ theme, type = "default" }) => ` - row-gap: ${theme.click.flyout.space[type].content["row-gap"]}; - column-gap: ${theme.click.flyout.space[type].content["column-gap"]}; - padding: ${theme.click.flyout.space[type].y} ${theme.click.flyout.space[type].content.x}; - `} -`; - interface FlyoutButtonProps extends Omit { children?: never; } @@ -447,46 +357,33 @@ const FlyoutClose = ({ FlyoutClose.displayName = "Flyout.Close"; Flyout.Close = FlyoutClose; -const FooterContainer = styled(Container)` - width: var(--flyout-width); - max-width: 100%; -`; - -const Footer = (props: FlyoutFooterProps) => { +const Footer = ({ type = "default", ...props }: FlyoutFooterProps) => { return ( - - - + ); }; Footer.displayName = "Flyout.Footer"; Flyout.Footer = Footer; -const CustomCodeBlock = styled(CodeBlock)` - display: flex; - height: 100%; - pre { - flex: 1; - overflow-wrap: break-word; - code { - display: inline-block; - max-width: calc(100% - 1rem); - } - } -`; - interface FlyoutCodeBlockProps extends ContainerProps { language?: string; statement: string; @@ -496,6 +393,7 @@ interface FlyoutCodeBlockProps extends ContainerProps { onCopy?: (value: string) => void | Promise; onCopyError?: (error: string) => void | Promise; } + const FlyoutCodeBlock = ({ statement, language, @@ -511,16 +409,17 @@ const FlyoutCodeBlock = ({ fillHeight {...props} > - {statement} - + ); diff --git a/src/components/FormContainer/FormContainer.tsx b/src/components/FormContainer/FormContainer.tsx index c22461216..0d242875a 100644 --- a/src/components/FormContainer/FormContainer.tsx +++ b/src/components/FormContainer/FormContainer.tsx @@ -1,8 +1,10 @@ -import { HTMLAttributes, ReactNode } from "react"; -import { Error, FormElementContainer, FormRoot } from "../commonElement"; +"use client"; + +import { ComponentPropsWithoutRef, ReactNode } from "react"; +import { Error, FormElementContainer, FormRoot } from "@/components/commonElement"; import { HorizontalDirection, Label, Orientation } from "@/components"; -export interface FormContainerProps extends HTMLAttributes { +export interface FormContainerProps extends ComponentPropsWithoutRef<"div"> { htmlFor: string; label?: ReactNode; orientation?: Orientation; @@ -23,9 +25,9 @@ export const FormContainer = ({ ...props }: FormContainerProps) => ( diff --git a/src/components/FormContainer/_mixins.scss b/src/components/FormContainer/_mixins.scss new file mode 100644 index 000000000..6ad51fe94 --- /dev/null +++ b/src/components/FormContainer/_mixins.scss @@ -0,0 +1,90 @@ +@use "../../styles/tokens-light-dark" as tokens; + +// Form and input mixins for Click UI form components + +// Form root mixin - base styles for form field containers +@mixin cuiFormRoot($align: flex-start, $maxWidth: null) { + display: flex; + box-sizing: border-box; + width: 100%; + gap: tokens.$clickFieldSpaceGap; + align-items: $align; + + @if $maxWidth { + max-width: $maxWidth; + } + + /* Reset box-shadow and outline for all children */ + * { + box-shadow: none; + outline: none; + } +} + +// Input base mixin - for custom input styling using click.input tokens +@mixin cuiInputBase { + display: flex; + align-items: center; + border-radius: var(--click-input-radii-default); + border: var(--click-input-stroke-default) solid var(--click-input-color-stroke-default); + background: var(--click-input-color-background-default); + transition: var(--click-input-transitions-all); + font: var(--click-input-typography-default); + color: var(--click-input-color-text-default); + + &:focus-within { + border-color: var(--click-input-color-stroke-focus); + background: var(--click-input-color-background-focus); + color: var(--click-input-color-text-focus); + } + + &:hover:not(:focus-within) { + border-color: var(--click-input-color-stroke-hover); + background: var(--click-input-color-background-hover); + color: var(--click-input-color-text-hover); + } + + &:disabled, + &.disabled { + border-color: var(--click-input-color-stroke-disabled); + background: var(--click-input-color-background-disabled); + color: var(--click-input-color-text-disabled); + cursor: not-allowed; + } +} + +// Input size variants - uses dynamic CSS custom properties +@mixin cuiInputSize($size) { + padding: var(--click-input-#{$size}-space-y) var(--click-input-#{$size}-space-x); + gap: var(--click-input-#{$size}-space-gap); + min-height: var(--click-input-#{$size}-size-height); +} + +// Input state variants - uses dynamic CSS custom properties +@mixin cuiInputState($state) { + border-color: var(--click-input-color-stroke-#{$state}); + background: var(--click-input-color-background-#{$state}); + color: var(--click-input-color-text-#{$state}); +} + +// Input element mixin - for the actual input element inside wrapper +@mixin cuiInputElement { + background: transparent; + border: none; + outline: none; + width: 100%; + color: inherit; + font: inherit; + padding: tokens.$clickFieldSpaceY 0; + + &::placeholder { + color: tokens.$clickFieldColorPlaceholderDefault; + } + + &:disabled, + &.disabled { + &::placeholder { + color: tokens.$clickFieldColorPlaceholderDisabled; + } + } +} diff --git a/src/components/GenericLabel/GenericLabel.module.scss b/src/components/GenericLabel/GenericLabel.module.scss new file mode 100644 index 000000000..20fa6123b --- /dev/null +++ b/src/components/GenericLabel/GenericLabel.module.scss @@ -0,0 +1,36 @@ +@use "cui-mixins" as mixins; +@use "cui-variants" as variants; + +.cuiGenericLabel { + cursor: pointer; + color: tokens.$clickFieldColorGenericLabelDefault; + font: tokens.$clickFieldTypographyGenericLabelDefault; + + &:hover { + color: tokens.$clickFieldColorGenericLabelHover; + font: tokens.$clickFieldTypographyGenericLabelHover; + } + + &:focus, + &:focus-within { + color: tokens.$clickFieldColorGenericLabelActive; + font: tokens.$clickFieldTypographyGenericLabelActive; + } + + @include variants.variant('cuiDisabled') { + color: tokens.$clickFieldColorGenericLabelDisabled; + font: tokens.$clickFieldTypographyGenericLabelDisabled; + cursor: not-allowed; + + &:hover { + color: tokens.$clickFieldColorGenericLabelDisabled; + font: tokens.$clickFieldTypographyGenericLabelDisabled; + } + + &:focus, + &:focus-within { + color: tokens.$clickFieldColorGenericLabelDisabled; + font: tokens.$clickFieldTypographyGenericLabelDisabled; + } + } +} diff --git a/src/components/GenericLabel/GenericLabel.stories.tsx b/src/components/GenericLabel/GenericLabel.stories.tsx index 546b2cb60..36e3291b1 100644 --- a/src/components/GenericLabel/GenericLabel.stories.tsx +++ b/src/components/GenericLabel/GenericLabel.stories.tsx @@ -1,25 +1,182 @@ -import { Meta, StoryObj } from "@storybook/react-vite"; +import type { Meta, StoryObj } from "@storybook/react-vite"; import { GenericLabel } from "./GenericLabel"; -const meta: Meta = { +const meta = { component: GenericLabel, title: "Forms/GenericLabel", tags: ["form-field", "generic-label", "autodocs"], -}; +} satisfies Meta; export default meta; -type Story = StoryObj; +type Story = StoryObj; export const Playground: Story = { args: { - children: "Form Field generic label", disabled: false, + htmlFor: "test", }, render: args => ( - {args.children} + Form Field generic label ), }; + +export const Variations: Story = { + parameters: { + controls: { disable: true }, + actions: { disable: true }, + pseudo: { + hover: ".cuiGenericLabel", + focus: ".cuiGenericLabel", + active: ".cuiGenericLabel", + }, + }, + render: () => ( +
+
+

States

+
+
+ Default Label +
+
+ Disabled Label +
+
+
+ +
+

With Input Fields

+
+ + Default Label + + + + Disabled Label + + +
+
+ +
+

With Different Form Controls

+
+ + Select Label + + + + Textarea Label +