Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/remove-styled-components.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@clickhouse/click-ui': major
---

Remove styled-components as a dependency. click-ui no longer uses styled-components internally — every component renders from CSS Modules, and the runtime theme is now delivered through click-ui's own React context (via `ClickUIProvider`).

Breaking changes:

- `styled-components` is no longer a peer dependency. If your app doesn't use styled-components for its own code, you can remove it.
- The deprecated `linkStyles` and `StyledLinkProps` exports have been removed. Use the `Link` component with the `component` prop instead — e.g. `<Link component={RouterLink} size="md" weight="normal" to="/path">text</Link>`.
- The lower-level `ThemeProvider` export has been removed; `ClickUIProvider` is now the single provider. It already set the theme (and wraps toast/tooltip), so replace any bare `<ThemeProvider theme={…}>` with `<ClickUIProvider theme={…}>`.
69 changes: 1 addition & 68 deletions .storybook/preview.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,6 @@
import { useState, useEffect, ReactNode } 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/providers";

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};
`
);
import { withTheme } from "./withThemeDecorator";

export const globalTypes = {
theme: {
Expand All @@ -37,55 +19,6 @@ export const globalTypes = {
},
};

const getSystemTheme = (): "dark" | "light" => {
if (typeof window !== "undefined" && window.matchMedia) {
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
return "dark";
};

interface ThemeWrapperProps {
themeSelection: string | undefined;
children: ReactNode;
}

const ThemeWrapper = ({ themeSelection, children }: ThemeWrapperProps) => {
const [systemTheme, setSystemTheme] = useState<"dark" | "light">(getSystemTheme);

// Listen for system theme changes
useEffect(() => {
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = () => {
setSystemTheme(mediaQuery.matches ? "dark" : "light");
};
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
}, []);

// Resolve the actual theme: handle "system" and fallback for undefined/null
const theme =
themeSelection === "system" || !themeSelection ? systemTheme : themeSelection;

return (
<ClickUIProvider theme={theme} config={{ tooltip: { delayDuration: 0 } }}>
<ThemeBlock $left>{children}</ThemeBlock>
</ClickUIProvider>
);
};

const withTheme: Decorator = (StoryFn, context) => {
const parameters = context.parameters;
const themeSelection = parameters?.theme || context.globals.theme;

return (
<ThemeWrapper themeSelection={themeSelection}>
<StoryFn />
</ThemeWrapper>
);
};

const preview: Preview = {
parameters: {
options: {
Expand Down
83 changes: 83 additions & 0 deletions .storybook/withThemeDecorator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { useState, useEffect, ReactNode } from "react";
import { Decorator } from "@storybook/react-vite";
import { ClickUIProvider } from "../src/providers";
import { useTheme } from "../src/theme/ThemeContext";

const ThemeBlock = ({
left,
bfill,
children,
}: {
left?: boolean;
bfill?: boolean;
children: ReactNode;
}) => {
const theme = useTheme();
return (
<div
style={{
position: "absolute",
top: "0.5rem",
left: left || bfill ? 0 : "50vw",
right: 0,
height: "fit-content",
bottom: 0,
overflow: "auto",
padding: "1rem",
boxSizing: "border-box",
background: theme.click.storybook.global.background,
}}
>
{children}
</div>
);
};

const getSystemTheme = (): "dark" | "light" => {
if (typeof window !== "undefined" && window.matchMedia) {
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
return "dark";
};

interface ThemeWrapperProps {
themeSelection: string | undefined;
children: ReactNode;
}

const ThemeWrapper = ({ themeSelection, children }: ThemeWrapperProps) => {
const [systemTheme, setSystemTheme] = useState<"dark" | "light">(getSystemTheme);

// Listen for system theme changes
useEffect(() => {
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = () => {
setSystemTheme(mediaQuery.matches ? "dark" : "light");
};
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
}, []);

// Resolve the actual theme: handle "system" and fallback for undefined/null
const theme =
themeSelection === "system" || !themeSelection ? systemTheme : themeSelection;

return (
<ClickUIProvider theme={theme} config={{ tooltip: { delayDuration: 0 } }}>
<ThemeBlock left>{children}</ThemeBlock>
</ClickUIProvider>
);
};

export const withTheme: Decorator = (StoryFn, context) => {
const parameters = context.parameters;
const themeSelection = parameters?.theme || context.globals.theme;

return (
<ThemeWrapper themeSelection={themeSelection}>
<StoryFn />
</ThemeWrapper>
);
};
10 changes: 3 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -424,8 +424,7 @@
"react-syntax-highlighter": "^16.1.0",
"react-virtualized-auto-sizer": "^1.0.20",
"react-window": "^1.8.9",
"sortablejs": "^1.15.0",
"styled-components": "^6.1.11"
"sortablejs": "^1.15.0"
},
"devDependencies": {
"@changesets/cli": "^2.29.8",
Expand All @@ -450,7 +449,6 @@
"@types/react-window": "^1.8.8",
"@types/sortablejs": "^1.15.2",
"@vitejs/plugin-react": "^5.1.2",
"babel-plugin-styled-components": "^2.1.4",
"chromatic": "^13.3.4",
"eslint": "^9",
"eslint-import-resolver-typescript": "^4.4.4",
Expand Down Expand Up @@ -492,13 +490,11 @@
"peerDependencies": {
"dayjs": "^1.11.19",
"react": "^18.3.1 || ^19.0.0",
"react-dom": "^18.3.1 || ^19.0.0",
"styled-components": "^6.1.11"
"react-dom": "^18.3.1 || ^19.0.0"
},
"resolutions": {
"@types/react": "18.3.1",
"@types/react-dom": "18.3.1",
"styled-components": "6.1.11"
"@types/react-dom": "18.3.1"
},
"packageManager": "yarn@4.5.3",
"engines": {
Expand Down
2 changes: 1 addition & 1 deletion src/components/Assets/Flags/system/Flag.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { SVGAttributes } from 'react';
import { useTheme } from 'styled-components';
import { useTheme } from '@/theme/ThemeContext';
import { getFallbackThemeName } from '@/theme/theme.utils';
import { SvgImageElement } from '@/components/Icon/SvgImageElement';
import { FlagName, FlagProps } from './types';
Expand All @@ -13,10 +13,10 @@

const resolveFlagName = createAssetResolver<FlagName>();

export { resolveFlagName };

Check warning on line 16 in src/components/Assets/Flags/system/Flag.tsx

View workflow job for this annotation

GitHub Actions / code-quality-checks

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components

export interface FlagPropsWithAliases extends Omit<FlagProps, 'name'> {
name: FlagName | AssetAlias | AssetDeprecatedName;

Check warning on line 19 in src/components/Assets/Flags/system/Flag.tsx

View workflow job for this annotation

GitHub Actions / code-quality-checks

`AssetDeprecatedName` is deprecated. Use the kebab-case name instead (e.g., 'activity' instead of 'Activity')
}

const Flag = ({ name, theme, size, ...props }: FlagPropsWithAliases) => {
Expand Down
2 changes: 1 addition & 1 deletion src/components/Assets/Icons/system/Icon.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { SVGAttributes } from 'react';
import { useTheme } from 'styled-components';
import { useTheme } from '@/theme/ThemeContext';
import { getFallbackThemeName } from '@/theme/theme.utils';
import { SvgImageElement } from '@/components/Icon/SvgImageElement';
import { IconName, IconProps } from './types';
Expand All @@ -13,10 +13,10 @@

const resolveIconName = createAssetResolver<IconName>();

export { resolveIconName };

Check warning on line 16 in src/components/Assets/Icons/system/Icon.tsx

View workflow job for this annotation

GitHub Actions / code-quality-checks

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components

export interface IconPropsWithAliases extends Omit<IconProps, 'name'> {
name: IconName | AssetAlias | AssetDeprecatedName;

Check warning on line 19 in src/components/Assets/Icons/system/Icon.tsx

View workflow job for this annotation

GitHub Actions / code-quality-checks

`AssetDeprecatedName` is deprecated. Use the kebab-case name instead (e.g., 'activity' instead of 'Activity')
}

const Icon = ({ name, theme, size, ...props }: IconPropsWithAliases) => {
Expand Down
2 changes: 1 addition & 1 deletion src/components/Assets/Logos/system/Logo.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { SVGAttributes } from 'react';
import { useTheme } from 'styled-components';
import { useTheme } from '@/theme/ThemeContext';
import { getFallbackThemeName } from '@/theme/theme.utils';
import { SvgImageElement } from '@/components/Icon/SvgImageElement';
import { LogoName, LogoProps } from './types';
Expand All @@ -13,10 +13,10 @@

const resolveLogoName = createAssetResolver<LogoName>();

export { resolveLogoName };

Check warning on line 16 in src/components/Assets/Logos/system/Logo.tsx

View workflow job for this annotation

GitHub Actions / code-quality-checks

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components

export interface LogoPropsWithAliases extends Omit<LogoProps, 'name'> {
name: LogoName | AssetAlias | AssetDeprecatedName;

Check warning on line 19 in src/components/Assets/Logos/system/Logo.tsx

View workflow job for this annotation

GitHub Actions / code-quality-checks

`AssetDeprecatedName` is deprecated. Use the kebab-case name instead (e.g., 'activity' instead of 'Activity')
}

const Logo = ({ name, theme, size, ...props }: LogoPropsWithAliases) => {
Expand Down
2 changes: 1 addition & 1 deletion src/components/Assets/Payments/system/Payment.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { SVGAttributes } from 'react';
import { useTheme } from 'styled-components';
import { useTheme } from '@/theme/ThemeContext';
import { getFallbackThemeName } from '@/theme/theme.utils';
import { SvgImageElement } from '@/components/Icon/SvgImageElement';
import { PaymentName, PaymentProps } from './types';
Expand All @@ -13,10 +13,10 @@

const resolvePaymentName = createAssetResolver<PaymentName>();

export { resolvePaymentName };

Check warning on line 16 in src/components/Assets/Payments/system/Payment.tsx

View workflow job for this annotation

GitHub Actions / code-quality-checks

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components

export interface PaymentPropsWithAliases extends Omit<PaymentProps, 'name'> {
name: PaymentName | AssetAlias | AssetDeprecatedName;

Check warning on line 19 in src/components/Assets/Payments/system/Payment.tsx

View workflow job for this annotation

GitHub Actions / code-quality-checks

`AssetDeprecatedName` is deprecated. Use the kebab-case name instead (e.g., 'activity' instead of 'Activity')
}

const Payment = ({ name, theme, size, ...props }: PaymentPropsWithAliases) => {
Expand Down
55 changes: 25 additions & 30 deletions src/components/Badge/Badge.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -108,9 +108,9 @@
font: var(--click-badge-typography-label-md-default);
}

/* Content row — mirrors the inner Content styled component. The original
`Content` was never passed `$size`, so its `gap` always defaulted to the
md token regardless of the badge's actual `size`. Preserved verbatim. */
/* Content row `gap` always uses the md token regardless of the badge's actual
`size` — matching the pre-migration behavior, where the row was never passed
a size. Preserved verbatim. */

.content {
display: inline-flex;
Expand All @@ -120,28 +120,25 @@
gap: var(--click-badge-space-md-gap);
}

/* BadgeContent — wraps the IconWrapper. The original styled(IconWrapper) set
`width: auto; overflow: hidden;` on the IconWrapper's rendered root and
styled the descendant <svg> with color, height, width, and `gap: inherit`.
/* BadgeContent — wraps the IconWrapper. Sets `width: auto; overflow: hidden;`
on the IconWrapper's root and styles the descendant <svg> with color,
height, width, and `gap: inherit`.

The styled-components version only forwarded `$state` to BadgeContent;
`$type` and `$size` were NOT passed and defaulted to `'opaque'` and `'md'`
inside the styled rule. To preserve that behavior verbatim, the descendant
<svg> rules below key off `state` only — color always comes from the
opaque-text-* tokens, and dimensions are always md, regardless of the
badge's actual `type`/`size`.
The content wrapper is keyed off `state` only — `type` and `size` are not
applied and effectively default to `'opaque'` and `'md'`, matching the
pre-migration behavior. So the descendant <svg> color always comes from the
opaque-text-* tokens and dimensions are always md, regardless of the badge's
actual `type`/`size`.

The doubled-class selectors below (e.g. `.badgecontent.badgecontent svg`)
raise specificity to (0,2,1) so these rules win over the Icon component's
own runtime-injected styled-components rule `.<sc-class> svg { width: ... }`
(specificity 0,1,1). styled-components used to inject the BadgeContent
stylesheet AFTER Icon's stylesheet at render time, so source order let
BadgeContent win at equal specificity; CSS Modules now bundle Badge's
stylesheet earlier than the runtime SC stylesheet, so we need the bump. */
own `svg` rule `.<icon-class> svg { width: ... }` (specificity 0,1,1). Badge
and Icon are both CSS Modules in the same cascade layer, and Badge's rule
bundles earlier, so at equal specificity Icon would win on source order —
the bump keeps BadgeContent's <svg> sizing authoritative. */

/* stylelint-disable no-descending-specificity -- doubled-class selectors below
raise specificity so they win over the Icon component's runtime-injected
styled-components `& svg` rule. */
raise specificity so they win over the Icon component's `& svg` rule. */

.badgecontent {
width: auto;
Expand Down Expand Up @@ -184,19 +181,17 @@

/* stylelint-enable no-descending-specificity */

/* Close icon — mirrors the SvgContainer styled.svg with as={Icon}. The
styled-components version only passed $state (size and type defaulted),
so width/height come from the default md/opaque tokens, and color from
the opaque-text-<state> token.
/* Close icon — rendered as an Icon, keyed off `state` only (size and type
default), so width/height come from the default md/opaque tokens and color
from the opaque-text-<state> token.

The state classes below use doubled-class specificity (0,2,1) so they
always win over Icon's internal `SvgWrapper` styled-component rule that
sets `color: currentColor` on the same element at single-class specificity.
Without the bump, stylesheet-order would decide and CSS Modules bundle
before runtime SC styles, so the close icon would inherit the wrapper's
color (e.g. opaque-text-info for an info badge, but solid-text-info for
a solid info badge). Doubled class keeps the close icon on opaque-text-*
regardless. */
always win over Icon's own `svg` rule that sets `color: currentColor` on
the same element at single-class specificity. Without the bump, source
order would decide — Badge and Icon are CSS Modules in the same layer and
Badge bundles first — so the close icon would inherit the wrapper's color
(e.g. opaque-text-info for an info badge, but solid-text-info for a solid
info badge). Doubled class keeps the close icon on opaque-text-* regardless. */

.closeicon {
width: var(--click-badge-icon-md-size-width);
Expand Down
11 changes: 5 additions & 6 deletions src/components/Badge/Badge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,11 @@ const wrapperVariants = cva(styles.wrapper, {
},
});

// The original `BadgeContent = styled(IconWrapper)` only forwarded `$state` —
// `$type` and `$size` were NOT passed and defaulted to `'opaque'` and `'md'`
// inside the styled rule. So the descendant <svg> color always came from the
// opaque-text-* tokens (regardless of the badge's actual `type`) and the
// SVG dimensions were always md (regardless of the badge's actual `size`).
// To preserve byte-for-byte behavior, this variant set keys off `state` only.
// This variant set intentionally keys off `state` only: the descendant <svg>
// color always comes from the opaque-text-* tokens (regardless of the badge's
// `type`) and the SVG dimensions are always md (regardless of its `size`).
// This matches the pre-migration behavior, where the content wrapper never
// received `type`/`size` and defaulted to `'opaque'`/`'md'`.
const badgeContentVariants = cva(styles.badgecontent, {
variants: {
state: {
Expand Down
6 changes: 2 additions & 4 deletions src/components/CodeBlock/CodeBlock.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,8 @@ const Decorator: NonNullable<Story['decorators']> = [
),
];

// Shows both the copy button and the wrap button. Both are rendered via
// `<CodeButton as={IconButton}>` (CodeButton = styled(EmptyButton)), exercising
// the `as`-prop path that bypasses EmptyButton's own render. This guards that
// path against the EmptyButton CSS Modules migration.
// Shows both the copy button and the wrap button, each rendered via IconButton.
// Keeps both buttons in the visual-regression suite.
export const WithWrapButton: Story = {
args: {
children: 'SELECT customer_id, total_spent FROM orders LIMIT 10;',
Expand Down
2 changes: 1 addition & 1 deletion src/components/CodeBlock/useColorStyle.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { CSSProperties } from 'react';
import { useTheme } from 'styled-components';
import { useTheme } from '@/theme/ThemeContext';
import { CodeThemeType } from './CodeBlock.types';

const useColorStyle = (defaultTheme?: CodeThemeType): Record<string, CSSProperties> => {
Expand Down
Loading
Loading