diff --git a/.changeset/eight-colts-accept.md b/.changeset/eight-colts-accept.md new file mode 100644 index 000000000..b48553c4e --- /dev/null +++ b/.changeset/eight-colts-accept.md @@ -0,0 +1,9 @@ +--- +'@clickhouse/click-ui': minor +--- + +This library will now use CSS Modules for styling and is distributed unbundled, giving your application full control over bundling and optimisations. This means you only include what you actually use, resulting in smaller bundle sizes and better performance! + +NOTE: We're currently migrating from Styled-Components to CSS Modules. Some components may still use Styled-Components during this transition period. + +To learn more about CSS modules support, check our documentation [here](https://github.com/ClickHouse/click-ui?tab=readme-ov-file#css-modules) diff --git a/README.md b/README.md index 3a6e824d3..9e92f7a5c 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ You can find the official docs for the Click UI design system and component libr * [Distribution](#distribution) - [Build](#build) - [Use Click UI](#use-click-ui) + - [CSS Modules](#css-modules) - [Deep imports support](#deep-imports-support) - [Examples](#examples) * [Themes](#themes) @@ -316,6 +317,39 @@ export default () => { To learn more about individual components, visit [Click UI components](https://clickhouse.design/click-ui). +### CSS Modules + +This library uses [CSS Modules](https://github.com/css-modules/css-modules) for styling and is distributed unbundled, giving your application full control over bundling and optimizations. This means you only include what you actually use, resulting in smaller bundle sizes and better performance! + +Most modern React frameworks support CSS Modules out of the box, including Next.js, Vite, Create React App, and TanStack Start, with no configuration required. + +> [!NOTE] +> We're currently migrating from Styled-Components to CSS Modules. Some components may still use Styled-Components during this transition period. +#### Benefits + +CSS Modules align naturally with component-level imports. When you import a component like `Button`, its `Button.module.css` is automatically included. If you don't use the component, neither the JavaScript, or CSS will be bundled in your application's output. Only the necessary stylesheets will be included in the output bundle. + +#### Custom Build Configurations + +Although most modern React setups have CSS Modules built-in, if your build tool doesn't support it by default, you'll need to configure it. + +Let's assume you have an old Webpack setup. Here's an example of how that'd look like: + +```js +{ + test: /\.module\.css$/, + use: [ + 'style-loader', + { + loader: 'css-loader', + options: { modules: true } + } + ] +} +``` + +For other bundlers, refer to their documentation on CSS Modules configuration. + ### Deep imports support Deep imports are supported, you can import directly from path. diff --git a/package.json b/package.json index de93758bf..ad6f50358 100644 --- a/package.json +++ b/package.json @@ -146,6 +146,7 @@ "vite": "^7.3.0", "vite-plugin-dts": "^4.3.0", "vite-plugin-externalize-deps": "^0.10.0", + "vite-plugin-static-copy": "^3.2.0", "vite-tsconfig-paths": "^6.0.5", "vitest": "^2.1.8", "watch": "^1.0.2" diff --git a/src/components/Button/Button.module.css b/src/components/Button/Button.module.css new file mode 100644 index 000000000..f16da373d --- /dev/null +++ b/src/components/Button/Button.module.css @@ -0,0 +1 @@ +.demoCSSModuleSetupOnlyDeleteAfter {} diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx index 21bee4e8e..6f9d3f491 100644 --- a/src/components/Button/Button.tsx +++ b/src/components/Button/Button.tsx @@ -5,6 +5,8 @@ import { styled, keyframes } from 'styled-components'; import { BaseButton } from '../commonElement'; import React from 'react'; +import styles from './Button.module.css'; + export type ButtonType = 'primary' | 'secondary' | 'empty' | 'danger'; type Alignment = 'center' | 'left'; @@ -42,6 +44,7 @@ export const Button = ({ ...delegated }: ButtonProps) => ( { const actualTZ = process.env.TZ; beforeAll(() => { - global.ResizeObserver = vi.fn(() => { - return { - observe: vi.fn(), - unobserve: vi.fn(), - disconnect: vi.fn(), - }; - }); + global.ResizeObserver = vi.fn(() => ({ + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), + })); process.env.TZ = 'America/New_York'; }); @@ -22,11 +20,11 @@ describe('DateDetails', () => { }); it('renders the DateDetails component with relevant timezone information', () => { - const baseDate = new Date('2024-12-24 11:45:00 AM'); + const baseDate = new Date('2024-12-24T11:45:00'); const systemTimeZone = 'America/Los_Angeles'; vi.setSystemTime(baseDate); - const fiveMinutesAgo = new Date('2024-12-24 11:40:00 AM'); + const fiveMinutesAgo = new Date('2024-12-24T11:40:00'); const { getByText } = renderCUI( { expect(trigger).toBeInTheDocument(); fireEvent.click(trigger); - expect( - getByText(content => { - return content.includes('EST'); - }) - ).toBeInTheDocument(); - expect( - getByText(content => { - return content.includes('PST'); - }) - ).toBeInTheDocument(); + + expect(getByText(/Dec 24, 11:40 a\.m\..*(EST|GMT-5)/)).toBeInTheDocument(); + expect(getByText(/Dec 24, 8:40 a\.m\..*(PST|GMT-8)/)).toBeInTheDocument(); expect(getByText('Dec 24, 4:40 p.m.')).toBeInTheDocument(); - expect(getByText('Dec 24, 11:40 a.m. (EST)')).toBeInTheDocument(); - expect(getByText('Dec 24, 8:40 a.m. (PST)')).toBeInTheDocument(); - expect(getByText(fiveMinutesAgo.getTime() / 1000)).toBeInTheDocument(); + expect(getByText(String(fiveMinutesAgo.getTime() / 1000))).toBeInTheDocument(); }); it('allows for not passing in a system timezone', () => { - const baseDate = new Date('2024-12-24 11:45:00 AM'); + const baseDate = new Date('2024-12-24T11:45:00'); vi.setSystemTime(baseDate); - const fiveMinutesAgo = new Date('2024-12-24 11:40:00 AM'); + const fiveMinutesAgo = new Date('2024-12-24T11:40:00'); const { getByText, queryByText } = renderCUI(); const trigger = getByText('5 minutes ago'); - expect(trigger).toBeInTheDocument(); - fireEvent.click(trigger); - expect( - getByText(content => { - return content.includes('EST'); - }) - ).toBeInTheDocument(); + expect(getByText(/Dec 24, 11:40 a\.m\..*(EST|GMT-5)/)).toBeInTheDocument(); expect(getByText('Dec 24, 4:40 p.m.')).toBeInTheDocument(); - expect(getByText('Dec 24, 11:40 a.m. (EST)')).toBeInTheDocument(); expect(queryByText('System')).not.toBeInTheDocument(); - expect(getByText(fiveMinutesAgo.getTime() / 1000)).toBeInTheDocument(); + expect(getByText(String(fiveMinutesAgo.getTime() / 1000))).toBeInTheDocument(); }); it("only shows the date if the previous date isn't in this year", () => { - const baseDate = new Date('2025-02-07 11:45:00 AM'); + const baseDate = new Date('2025-02-07T11:45:00'); const systemTimeZone = 'America/Los_Angeles'; vi.setSystemTime(baseDate); - const oneYearAgo = new Date('2024-02-07 11:45:00 AM'); + const oneYearAgo = new Date('2024-02-07T11:45:00'); const { getByText } = renderCUI( { ); const trigger = getByText('1 year ago'); - expect(trigger).toBeInTheDocument(); - fireEvent.click(trigger); + expect(getByText('Feb 7, 2024, 4:45 p.m.')).toBeInTheDocument(); - expect(getByText('Feb 7, 2024, 11:45 a.m. (EST)')).toBeInTheDocument(); - expect(getByText('Feb 7, 2024, 8:45 a.m. (PST)')).toBeInTheDocument(); - expect(getByText(oneYearAgo.getTime() / 1000)).toBeInTheDocument(); + expect(getByText(/Feb 7, 2024, 11:45 a\.m\..*(EST|GMT-5)/)).toBeInTheDocument(); + expect(getByText(/Feb 7, 2024, 8:45 a\.m\..*(PST|GMT-8)/)).toBeInTheDocument(); + expect(getByText(String(oneYearAgo.getTime() / 1000))).toBeInTheDocument(); }); it('handles Daylight Savings Time', () => { - const baseDate = new Date('2024-07-04 11:45:00 AM'); + const baseDate = new Date('2024-07-04T11:45:00'); const systemTimeZone = 'America/Los_Angeles'; vi.setSystemTime(baseDate); - const fiveMinutesAgo = new Date('2024-07-04 11:40:00 AM'); + const fiveMinutesAgo = new Date('2024-07-04T11:40:00'); const { getByText } = renderCUI( { ); const trigger = getByText('5 minutes ago'); - expect(trigger).toBeInTheDocument(); - fireEvent.click(trigger); - expect( - getByText(content => { - return content.includes('EDT'); - }) - ).toBeInTheDocument(); - expect( - getByText(content => { - return content.includes('PDT'); - }) - ).toBeInTheDocument(); + + expect(getByText(/Jul 4, 11:40 a\.m\..*(EDT|GMT-4)/)).toBeInTheDocument(); + expect(getByText(/Jul 4, 8:40 a\.m\..*(PDT|GMT-7)/)).toBeInTheDocument(); expect(getByText('Jul 4, 3:40 p.m.')).toBeInTheDocument(); - expect(getByText('Jul 4, 11:40 a.m. (EDT)')).toBeInTheDocument(); - expect(getByText('Jul 4, 8:40 a.m. (PDT)')).toBeInTheDocument(); - expect(getByText(fiveMinutesAgo.getTime() / 1000)).toBeInTheDocument(); + expect(getByText(String(fiveMinutesAgo.getTime() / 1000))).toBeInTheDocument(); }); }); diff --git a/vite.config.ts b/vite.config.ts index 6b440fd67..0ec01b4f2 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,9 +6,17 @@ import dts from 'vite-plugin-dts'; import { externalizeDeps } from 'vite-plugin-externalize-deps'; import tsconfigPaths from 'vite-tsconfig-paths'; import { visualizer } from 'rollup-plugin-visualizer'; +import { viteStaticCopy } from 'vite-plugin-static-copy'; const srcDir = path.resolve(__dirname, 'src').replace(/\\/g, '/'); +// TODO: Find a solution for static files based on conf extensions +const cssExternalPlugin = () => ({ + name: 'css-external', + enforce: 'pre' as const, + resolveId: (id: string) => (id.endsWith('.module.css') ? { id, external: true } : null), +}); + const buildOptions: BuildOptions = { target: 'esnext', emptyOutDir: true, @@ -51,6 +59,7 @@ const buildOptions: BuildOptions = { const viteConfig = defineConfig({ publicDir: false, plugins: [ + cssExternalPlugin(), react({ babel: { plugins: [['babel-plugin-styled-components', { displayName: false }]], @@ -85,6 +94,37 @@ const viteConfig = defineConfig({ useFile: path.join(process.cwd(), 'package.json'), }), tsconfigPaths(), + // TODO: Copying CSS Module files to both esm and cjs dist directories should have the target names, e.g. esm, cjs shared with bundled target, so that they're automatically sync. + viteStaticCopy({ + targets: [ + { + src: 'src/**/*.module.css', + dest: 'esm', + rename: (fileName: string, fileExt: string, srcPath: string) => { + const srcIndex = srcPath.indexOf('/src/'); + const ext = fileExt.startsWith('.') ? fileExt : `.${fileExt}`; + if (srcIndex !== -1) { + const relativePath = srcPath.slice(srcIndex + 5, srcPath.lastIndexOf('/')); + return `${relativePath}/${fileName}${ext}`; + } + return `${fileName}${ext}`; + }, + }, + { + src: 'src/**/*.module.css', + dest: 'cjs', + rename: (fileName: string, fileExt: string, srcPath: string) => { + const srcIndex = srcPath.indexOf('/src/'); + const ext = fileExt.startsWith('.') ? fileExt : `.${fileExt}`; + if (srcIndex !== -1) { + const relativePath = srcPath.slice(srcIndex + 5, srcPath.lastIndexOf('/')); + return `${relativePath}/${fileName}${ext}`; + } + return `${fileName}${ext}`; + }, + }, + ], + }), // WARNING: Keep the visualizer last ...(process.env.ANALYZE === 'true' ? [ diff --git a/yarn.lock b/yarn.lock index 76233dd2a..9c8ecbbe7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -831,6 +831,7 @@ __metadata: vite: "npm:^7.3.0" vite-plugin-dts: "npm:^4.3.0" vite-plugin-externalize-deps: "npm:^0.10.0" + vite-plugin-static-copy: "npm:^3.2.0" vite-tsconfig-paths: "npm:^6.0.5" vitest: "npm:^2.1.8" watch: "npm:^1.0.2" @@ -4895,6 +4896,16 @@ __metadata: languageName: node linkType: hard +"anymatch@npm:~3.1.2": + version: 3.1.3 + resolution: "anymatch@npm:3.1.3" + dependencies: + normalize-path: "npm:^3.0.0" + picomatch: "npm:^2.0.4" + checksum: 10c0/57b06ae984bc32a0d22592c87384cd88fe4511b1dd7581497831c56d41939c8a001b28e7b853e1450f2bf61992dfcaa8ae2d0d161a0a90c4fb631ef07098fbac + languageName: node + linkType: hard + "arg@npm:^4.1.0": version: 4.1.3 resolution: "arg@npm:4.1.3" @@ -5143,6 +5154,13 @@ __metadata: languageName: node linkType: hard +"binary-extensions@npm:^2.0.0": + version: 2.3.0 + resolution: "binary-extensions@npm:2.3.0" + checksum: 10c0/75a59cafc10fb12a11d510e77110c6c7ae3f4ca22463d52487709ca7f18f69d886aa387557cc9864fbdb10153d0bdb4caacabf11541f55e89ed6e18d12ece2b5 + languageName: node + linkType: hard + "boolbase@npm:^1.0.0": version: 1.0.0 resolution: "boolbase@npm:1.0.0" @@ -5169,7 +5187,7 @@ __metadata: languageName: node linkType: hard -"braces@npm:^3.0.3": +"braces@npm:^3.0.3, braces@npm:~3.0.2": version: 3.0.3 resolution: "braces@npm:3.0.3" dependencies: @@ -5370,6 +5388,25 @@ __metadata: languageName: node linkType: hard +"chokidar@npm:^3.6.0": + version: 3.6.0 + resolution: "chokidar@npm:3.6.0" + dependencies: + anymatch: "npm:~3.1.2" + braces: "npm:~3.0.2" + fsevents: "npm:~2.3.2" + glob-parent: "npm:~5.1.2" + is-binary-path: "npm:~2.1.0" + is-glob: "npm:~4.0.1" + normalize-path: "npm:~3.0.0" + readdirp: "npm:~3.6.0" + dependenciesMeta: + fsevents: + optional: true + checksum: 10c0/8361dcd013f2ddbe260eacb1f3cb2f2c6f2b0ad118708a343a5ed8158941a39cb8fb1d272e0f389712e74ee90ce8ba864eece9e0e62b9705cb468a2f6d917462 + languageName: node + linkType: hard + "chownr@npm:^3.0.0": version: 3.0.0 resolution: "chownr@npm:3.0.0" @@ -7070,7 +7107,7 @@ __metadata: languageName: node linkType: hard -"glob-parent@npm:^5.1.2": +"glob-parent@npm:^5.1.2, glob-parent@npm:~5.1.2": version: 5.1.2 resolution: "glob-parent@npm:5.1.2" dependencies: @@ -7538,6 +7575,15 @@ __metadata: languageName: node linkType: hard +"is-binary-path@npm:~2.1.0": + version: 2.1.0 + resolution: "is-binary-path@npm:2.1.0" + dependencies: + binary-extensions: "npm:^2.0.0" + checksum: 10c0/a16eaee59ae2b315ba36fad5c5dcaf8e49c3e27318f8ab8fa3cdb8772bf559c8d1ba750a589c2ccb096113bb64497084361a25960899cb6172a6925ab6123d38 + languageName: node + linkType: hard + "is-boolean-object@npm:^1.2.1": version: 1.2.2 resolution: "is-boolean-object@npm:1.2.2" @@ -7655,7 +7701,7 @@ __metadata: languageName: node linkType: hard -"is-glob@npm:^4.0.0, is-glob@npm:^4.0.1, is-glob@npm:^4.0.3": +"is-glob@npm:^4.0.0, is-glob@npm:^4.0.1, is-glob@npm:^4.0.3, is-glob@npm:~4.0.1": version: 4.0.3 resolution: "is-glob@npm:4.0.3" dependencies: @@ -8585,6 +8631,13 @@ __metadata: languageName: node linkType: hard +"normalize-path@npm:^3.0.0, normalize-path@npm:~3.0.0": + version: 3.0.0 + resolution: "normalize-path@npm:3.0.0" + checksum: 10c0/e008c8142bcc335b5e38cf0d63cfd39d6cf2d97480af9abdbe9a439221fd4d749763bab492a8ee708ce7a194bb00c9da6d0a115018672310850489137b3da046 + languageName: node + linkType: hard + "nth-check@npm:^2.0.1": version: 2.1.1 resolution: "nth-check@npm:2.1.1" @@ -8797,7 +8850,7 @@ __metadata: languageName: node linkType: hard -"p-map@npm:^7.0.2": +"p-map@npm:^7.0.2, p-map@npm:^7.0.4": version: 7.0.4 resolution: "p-map@npm:7.0.4" checksum: 10c0/a5030935d3cb2919d7e89454d1ce82141e6f9955413658b8c9403cfe379283770ed3048146b44cde168aa9e8c716505f196d5689db0ae3ce9a71521a2fef3abd @@ -8962,7 +9015,7 @@ __metadata: languageName: node linkType: hard -"picomatch@npm:^2.3.1": +"picomatch@npm:^2.0.4, picomatch@npm:^2.2.1, picomatch@npm:^2.3.1": version: 2.3.1 resolution: "picomatch@npm:2.3.1" checksum: 10c0/26c02b8d06f03206fc2ab8d16f19960f2ff9e81a658f831ecb656d8f17d9edc799e8364b1f4a7873e89d9702dff96204be0fa26fe4181f6843f040f819dac4be @@ -9432,6 +9485,15 @@ __metadata: languageName: node linkType: hard +"readdirp@npm:~3.6.0": + version: 3.6.0 + resolution: "readdirp@npm:3.6.0" + dependencies: + picomatch: "npm:^2.2.1" + checksum: 10c0/6fa848cf63d1b82ab4e985f4cf72bd55b7dcfd8e0a376905804e48c3634b7e749170940ba77b32804d5fe93b3cc521aa95a8d7e7d725f830da6d93f3669ce66b + languageName: node + linkType: hard + "recast@npm:^0.23.5": version: 0.23.11 resolution: "recast@npm:0.23.11" @@ -11036,6 +11098,20 @@ __metadata: languageName: node linkType: hard +"vite-plugin-static-copy@npm:^3.2.0": + version: 3.2.0 + resolution: "vite-plugin-static-copy@npm:3.2.0" + dependencies: + chokidar: "npm:^3.6.0" + p-map: "npm:^7.0.4" + picocolors: "npm:^1.1.1" + tinyglobby: "npm:^0.2.15" + peerDependencies: + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 + checksum: 10c0/84fabc3cc49b3de7d487045569db105e2c3397eea7ca41ac43651f3e96daae4a0da822f6d493dbede8746c93b123ee30918ebd21cebe7b4f8051fe91e393c2fd + languageName: node + linkType: hard + "vite-tsconfig-paths@npm:^6.0.5": version: 6.0.5 resolution: "vite-tsconfig-paths@npm:6.0.5"