Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/migrate-emptybutton-to-css-modules.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clickhouse/click-ui': patch
---

Migrate EmptyButton from styled-components to css modules with no change in behavior
24 changes: 24 additions & 0 deletions src/components/CodeBlock/CodeBlock.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,30 @@ export default meta;

type Story = StoryObj<typeof CodeBlock>;

// 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.
export const WithWrapButton: Story = {
args: {
children: 'SELECT customer_id, total_spent FROM orders LIMIT 10;',
language: 'sql',
showLineNumbers: true,
showWrapButton: true,
wrapLines: false,
},
decorators: [
Story => (
<div
data-testid="codeblock-harness"
style={{ display: 'inline-block', padding: '24px' }}
>
<Story />
</div>
),
],
};

export const Playground: Story = {
args: {
children: `SELECT
Expand Down
21 changes: 21 additions & 0 deletions src/components/EmptyButton/EmptyButton.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/* Kept at zero specificity via :where() so that downstream styled-components
overrides on a styled(EmptyButton) (e.g. CrossButton overriding background and
padding, the Popover/CodeBlock close/copy buttons overriding color and padding)
win without needing a specificity hack — EmptyButton is a CSS Module, whose
rules otherwise beat equal-specificity styled-components by source order. */
:where(.empty-button) {
padding: 0;
border: 0;
background: transparent;
color: inherit;
font: inherit;
outline: none;
cursor: pointer;
}

/* Also scoped with :where() so the disabled cursor stays at zero specificity
and a downstream styled(EmptyButton) `&:disabled` override wins naturally,
rather than tying on specificity and losing to the CSS Module by source order. */
:where(.empty-button):disabled {
cursor: not-allowed;
}
47 changes: 47 additions & 0 deletions src/components/EmptyButton/EmptyButton.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { Meta, StoryObj } from '@storybook/react-vite';
import { EmptyButton } from '@/components/EmptyButton';

const meta: Meta<typeof EmptyButton> = {
component: EmptyButton,
title: 'Buttons/EmptyButton',
tags: ['emptybutton', 'autodocs'],
// EmptyButton resets to a transparent, padding-less button that inherits its
// parent's color and font. The decorator gives it a contrasting parent with a
// distinct color and font so the inheritance is visible and measurable in the
// snapshot.
decorators: [
Story => (
<div
data-testid="emptybutton-harness"
style={{
display: 'inline-flex',
padding: '24px',
color: '#c2185b',
fontFamily: 'Georgia, serif',
fontSize: '20px',
fontStyle: 'italic',
background: '#f5f5f5',
}}
>
<Story />
</div>
),
],
};

export default meta;

type Story = StoryObj<typeof EmptyButton>;

export const Default: Story = {
args: {
children: 'Empty button',
},
};

export const Disabled: Story = {
args: {
children: 'Disabled empty button',
disabled: true,
},
};
29 changes: 15 additions & 14 deletions src/components/EmptyButton/EmptyButton.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import { styled } from 'styled-components';
import { ButtonHTMLAttributes, forwardRef } from 'react';
import { cn } from '@/lib/cva';
import styles from './EmptyButton.module.css';

export const EmptyButton = styled.button`
background: transparent;
border: none;
cursor: pointer;
outline: none;
padding: 0;
border: 0;
color: inherit;
font: inherit;
&:disabled {
cursor: not-allowed;
}
`;
export const EmptyButton = forwardRef<
HTMLButtonElement,
ButtonHTMLAttributes<HTMLButtonElement>
>(({ className, ...props }, ref) => (
<button
ref={ref}
{...props}
className={cn(styles['empty-button'], className)}
/>
));
Comment thread
cursor[bot] marked this conversation as resolved.

EmptyButton.displayName = 'EmptyButton';
27 changes: 27 additions & 0 deletions src/components/Popover/Popover.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,30 @@ export const Playground: Story = {
</GridCenter>
),
};

// Open-by-default so visual regression can screenshot the content panel and its
// close button without an interaction step. The close button is rendered via
// `<CloseButton as={RadixPopover.Close}>` (CloseButton = styled(EmptyButton)),
// exercising the `as`-prop path that bypasses EmptyButton's own render. This
// guards that path against the EmptyButton CSS Modules migration.
export const OpenWithClose: Story = {
args: {
modal: false,
open: true,
},
render: args => (
<GridCenter>
<Popover {...args}>
<Popover.Trigger>Click Here</Popover.Trigger>
<Popover.Content
side="bottom"
showClose
>
<Title type="h2">Content popover</Title>
<br />
<Text>Popover with a close button.</Text>
</Popover.Content>
</Popover>
</GridCenter>
),
};
123 changes: 123 additions & 0 deletions tests/buttons/emptybutton.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// Affected-spec coverage for scoped visual-regression runs in CI.
// See .scripts/js/affected-visual-specs
// @covers src/components/EmptyButton
import { test as it, expect } from '@playwright/test';
import { getStoryUrl } from '../utils';

const { describe, use } = it;

const harnessLocator = '[data-testid="emptybutton-harness"]';

describe('EmptyButton Visual Regression', () => {
describe('Light Theme (Storybook Global)', () => {
it('default matches snapshot', async ({ page }) => {
await page.goto(getStoryUrl('buttons-emptybutton--default', 'light'), {
waitUntil: 'networkidle',
});
const harness = page.locator(harnessLocator);
await expect(harness).toBeVisible({ timeout: 10000 });
await expect(harness).toHaveScreenshot('emptybutton-default-light.png', {
maxDiffPixels: 100,
});
});

it('disabled matches snapshot', async ({ page }) => {
await page.goto(getStoryUrl('buttons-emptybutton--disabled', 'light'), {
waitUntil: 'networkidle',
});
const harness = page.locator(harnessLocator);
await expect(harness).toBeVisible({ timeout: 10000 });
const button = page.getByRole('button');
await expect(button).toBeDisabled();
await expect(harness).toHaveScreenshot('emptybutton-disabled-light.png', {
maxDiffPixels: 100,
});
});

it('hover state matches snapshot', async ({ page }) => {
await page.goto(getStoryUrl('buttons-emptybutton--default', 'light'), {
waitUntil: 'networkidle',
});
const harness = page.locator(harnessLocator);
await expect(harness).toBeVisible({ timeout: 10000 });
await page.getByRole('button').hover();
await page.waitForTimeout(100);
await expect(harness).toHaveScreenshot('emptybutton-hover-light.png', {
maxDiffPixels: 100,
});
});

it('focus state matches snapshot', async ({ page }) => {
await page.goto(getStoryUrl('buttons-emptybutton--default', 'light'), {
waitUntil: 'networkidle',
});
const harness = page.locator(harnessLocator);
await expect(harness).toBeVisible({ timeout: 10000 });
await page.getByRole('button').focus();
await page.waitForTimeout(100);
await expect(harness).toHaveScreenshot('emptybutton-focus-light.png', {
maxDiffPixels: 100,
});
});
});

describe('Dark Theme (System prefers-color-scheme)', () => {
use({ colorScheme: 'dark' });

it('default matches snapshot', async ({ page }) => {
await page.goto(getStoryUrl('buttons-emptybutton--default'), {
waitUntil: 'networkidle',
});
const harness = page.locator(harnessLocator);
await expect(harness).toBeVisible({ timeout: 10000 });
await expect(harness).toHaveScreenshot('emptybutton-default-dark.png', {
maxDiffPixels: 100,
});
});

it('disabled matches snapshot', async ({ page }) => {
await page.goto(getStoryUrl('buttons-emptybutton--disabled'), {
waitUntil: 'networkidle',
});
const harness = page.locator(harnessLocator);
await expect(harness).toBeVisible({ timeout: 10000 });
const button = page.getByRole('button');
await expect(button).toBeDisabled();
await expect(harness).toHaveScreenshot('emptybutton-disabled-dark.png', {
maxDiffPixels: 100,
});
});
});

describe('Behavior', () => {
it('click event fires', async ({ page }) => {
await page.goto(getStoryUrl('buttons-emptybutton--default', 'light'), {
waitUntil: 'networkidle',
});
const button = page.getByRole('button');
await expect(button).toBeVisible({ timeout: 10000 });
await expect(button).toBeEnabled();
await button.click();
});

it('disabled button reports disabled', async ({ page }) => {
await page.goto(getStoryUrl('buttons-emptybutton--disabled', 'light'), {
waitUntil: 'networkidle',
});
const button = page.getByRole('button');
await expect(button).toBeDisabled();
});

it('keyboard navigation works', async ({ page }) => {
await page.goto(getStoryUrl('buttons-emptybutton--default', 'light'), {
waitUntil: 'networkidle',
});
const button = page.getByRole('button');
await expect(button).toBeVisible({ timeout: 10000 });
await page.locator('body').click();
await page.keyboard.press('Tab');
await expect(button).toBeFocused();
await page.keyboard.press('Enter');
});
});
});
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
69 changes: 69 additions & 0 deletions tests/codeblocks/codeblock.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// Affected-spec coverage for scoped visual-regression runs in CI.
// See .scripts/js/affected-visual-specs
// @covers src/components/CodeBlock
// @covers src/components/EmptyButton
import { test as it, expect } from '@playwright/test';
import { getStoryUrl } from '../utils';

const { describe, use } = it;

// The copy and wrap buttons are rendered via `<CodeButton as={IconButton}>` where
// CodeButton = styled(EmptyButton). The `as` prop bypasses EmptyButton's own
// render, so this spec guards that those buttons still render correctly after
// EmptyButton's CSS Modules migration. The WithWrapButton story shows both.
const rootLocator = '[data-testid="codeblock-harness"]';

describe('CodeBlock Visual Regression', () => {
describe('Light Theme (Storybook Global)', () => {
it('with wrap button matches snapshot', async ({ page }) => {
await page.goto(getStoryUrl('codeblocks-codeblock--with-wrap-button', 'light'), {
waitUntil: 'networkidle',
});
const root = page.locator(rootLocator);
await expect(root).toBeVisible({ timeout: 10000 });
await expect(page.getByRole('button').first()).toBeVisible();
await expect(root).toHaveScreenshot('codeblock-with-wrap-button-light.png', {
maxDiffPixels: 100,
});
});

it('copied state matches snapshot', async ({ page }) => {
await page.goto(getStoryUrl('codeblocks-codeblock--with-wrap-button', 'light'), {
waitUntil: 'networkidle',
});
const root = page.locator(rootLocator);
await expect(root).toBeVisible({ timeout: 10000 });
// The last button is the copy button; clicking it flips to the copied state.
await page.getByRole('button').last().click();
await page.waitForTimeout(100);
await expect(root).toHaveScreenshot('codeblock-copied-light.png', {
maxDiffPixels: 100,
});
});
});

describe('Dark Theme (System prefers-color-scheme)', () => {
use({ colorScheme: 'dark' });

it('with wrap button matches snapshot', async ({ page }) => {
await page.goto(getStoryUrl('codeblocks-codeblock--with-wrap-button'), {
waitUntil: 'networkidle',
});
const root = page.locator(rootLocator);
await expect(root).toBeVisible({ timeout: 10000 });
await expect(page.getByRole('button').first()).toBeVisible();
await expect(root).toHaveScreenshot('codeblock-with-wrap-button-dark.png', {
maxDiffPixels: 100,
});
});
});

describe('Behavior', () => {
it('renders copy and wrap buttons', async ({ page }) => {
await page.goto(getStoryUrl('codeblocks-codeblock--with-wrap-button', 'light'), {
waitUntil: 'networkidle',
});
await expect(page.getByRole('button')).toHaveCount(2);
});
});
});
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading