diff --git a/.changeset/migrate-label-to-css-modules.md b/.changeset/migrate-label-to-css-modules.md new file mode 100644 index 000000000..aa427b8c6 --- /dev/null +++ b/.changeset/migrate-label-to-css-modules.md @@ -0,0 +1,5 @@ +--- +'@clickhouse/click-ui': patch +--- + +Migrate Label from styled-components to css modules with no change in behavior diff --git a/src/components/Label/Label.module.css b/src/components/Label/Label.module.css new file mode 100644 index 000000000..0d88ad649 --- /dev/null +++ b/src/components/Label/Label.module.css @@ -0,0 +1,32 @@ +.label { + color: var(--click-field-color-label-default); + font: var(--click-field-typography-label-default); +} + +/* Hover/focus rules only apply when neither disabled nor error is set, matching + the original styled-components behavior (which simply didn't emit them in + those states). The :not() chain also raises specificity above 0-1-0 so these + rules win over InputWrapper's `labelColor` override (a 0-1-0 single-class + styled-components selector injected later in the cascade). The .label_error + and .label_disabled modifiers below stay at 0-1-0 on purpose, so labelColor + continues to override them. */ +.label:not(.label_error, .label_disabled):hover { + color: var(--click-field-color-label-hover); + font: var(--click-field-typography-label-hover); +} + +.label:not(.label_error, .label_disabled):focus, +.label:not(.label_error, .label_disabled):focus-within { + color: var(--click-field-color-label-active); + font: var(--click-field-typography-label-active); +} + +.label_error { + color: var(--click-field-color-label-error); + font: var(--click-field-typography-label-error); +} + +.label_disabled { + color: var(--click-field-color-label-disabled); + font: var(--click-field-typography-label-disabled); +} diff --git a/src/components/Label/Label.stories.tsx b/src/components/Label/Label.stories.tsx index a12286ff4..07fdecd1e 100644 --- a/src/components/Label/Label.stories.tsx +++ b/src/components/Label/Label.stories.tsx @@ -1,5 +1,6 @@ import { Meta, StoryObj } from '@storybook/react-vite'; import { Label } from '@/components/Label'; +import type { LabelProps } from './Label.types'; const meta: Meta = { component: Label, @@ -11,6 +12,28 @@ export default meta; type Story = StoryObj; +// Nest the input inside Label so the browser associates them implicitly. +// Explicit htmlFor/id would collide across stories on the autodocs page, +// since each story renders in its own React root and shares the document. +const LabelHarness = ({ disabled, error, children }: LabelProps) => ( +
+ +
+); + export const Playground: Story = { args: { children: 'Form Field label', @@ -20,7 +43,19 @@ export const Playground: Story = { render: args => ( ), }; + +export const Default: Story = { + render: () => Form Field label, +}; + +export const Disabled: Story = { + render: () => Form Field label, +}; + +export const Error: Story = { + render: () => Form Field label, +}; diff --git a/src/components/Label/Label.tsx b/src/components/Label/Label.tsx index eb34f27b0..e82364df0 100644 --- a/src/components/Label/Label.tsx +++ b/src/components/Label/Label.tsx @@ -1,47 +1,29 @@ -import { styled } from 'styled-components'; +import { cn, cva } from '@/lib/cva'; import { LabelProps } from './Label.types'; +import styles from './Label.module.css'; -interface FormFieldLableProps { - disabled?: boolean; - $error?: boolean; - htmlFor?: string; -} +const labelVariants = cva(styles.label, { + variants: { + disabled: { + true: styles['label_disabled'], + false: '', + }, + error: { + true: styles['label_error'], + false: '', + }, + }, + defaultVariants: { + disabled: false, + error: false, + }, +}); -const FormFieldLabel = styled.label` - ${({ theme, disabled, $error }) => ` - ${ - disabled - ? ` - color: ${theme.click.field.color.label.disabled}; - font: ${theme.click.field.typography.label.disabled}; - ` - : $error - ? ` - color: ${theme.click.field.color.label.error}; - font: ${theme.click.field.typography.label.error}; - ` - : ` - color: ${theme.click.field.color.label.default}; - font: ${theme.click.field.typography.label.default}; - &:hover { - color: ${theme.click.field.color.label.hover}; - font: ${theme.click.field.typography.label.hover}; - } - &:focus, &:focus-within { - color: ${theme.click.field.color.label.active}; - font: ${theme.click.field.typography.label.active}; - } - ` - }; - `} -`; - -export const Label = ({ disabled, error, children, ...props }: LabelProps) => ( - ( + + ); diff --git a/src/components/TextField/TextField.test.tsx b/src/components/TextField/TextField.test.tsx index ad58ef5f8..31f1d5140 100644 --- a/src/components/TextField/TextField.test.tsx +++ b/src/components/TextField/TextField.test.tsx @@ -26,7 +26,7 @@ const TextFieldWrapper = ({ describe('TextField', () => { describe('label color', () => { - it('is the default color when labelColor is not set', () => { + it('renders the label without an explicit color override when labelColor is not set', () => { const label = 'Hello there!'; const text = 'General Kenobi'; @@ -39,7 +39,14 @@ describe('TextField', () => { const labelElement = getByText(label); expect(labelElement).toBeInTheDocument(); - expect(labelElement).toHaveStyle('color: rgb(179, 182, 189)'); + // The Label's default color is set via the CSS Module rule + // `.label { color: var(--click-field-color-label-default) }`, which + // jsdom does not load. Visual parity is covered by the Playwright + // suite at tests/display/label.spec.ts. The assertion below verifies + // the InputWrapper does not inject a styled-components color override + // when no labelColor prop is passed (element.style.color is the empty + // string when no inline `color` is set). + expect(labelElement.style.color).toBe(''); }); it('is the color of the passed in labelColor', () => { diff --git a/tests/display/label.spec.ts b/tests/display/label.spec.ts new file mode 100644 index 000000000..140ffe145 --- /dev/null +++ b/tests/display/label.spec.ts @@ -0,0 +1,144 @@ +import { test as it, expect } from '@playwright/test'; +import { getStoryUrl } from '../utils'; + +const { describe, use } = it; + +const harnessLocator = '[data-testid="label-harness"]'; + +describe('Label Visual Regression', () => { + describe('Light Theme (Storybook Global)', () => { + describe('Variants', () => { + it('default matches snapshot', async ({ page }) => { + await page.goto(getStoryUrl('forms-label--default', 'light'), { + waitUntil: 'networkidle', + }); + const harness = page.locator(harnessLocator).first(); + await expect(harness).toBeVisible({ timeout: 10000 }); + await expect(harness).toHaveScreenshot('label-default-light.png', { + maxDiffPixels: 100, + }); + }); + + it('disabled matches snapshot', async ({ page }) => { + await page.goto(getStoryUrl('forms-label--disabled', 'light'), { + waitUntil: 'networkidle', + }); + const harness = page.locator(harnessLocator).first(); + await expect(harness).toBeVisible({ timeout: 10000 }); + await expect(harness).toHaveScreenshot('label-disabled-light.png', { + maxDiffPixels: 100, + }); + }); + + it('error matches snapshot', async ({ page }) => { + await page.goto(getStoryUrl('forms-label--error', 'light'), { + waitUntil: 'networkidle', + }); + const harness = page.locator(harnessLocator).first(); + await expect(harness).toBeVisible({ timeout: 10000 }); + await expect(harness).toHaveScreenshot('label-error-light.png', { + maxDiffPixels: 100, + }); + }); + }); + + describe('Interactive States', () => { + it('hover state matches snapshot', async ({ page }) => { + await page.goto(getStoryUrl('forms-label--default', 'light'), { + waitUntil: 'networkidle', + }); + const harness = page.locator(harnessLocator).first(); + await expect(harness).toBeVisible({ timeout: 10000 }); + const label = harness.locator('label').first(); + await label.hover(); + await page.waitForTimeout(100); + await expect(harness).toHaveScreenshot('label-hover-light.png', { + maxDiffPixels: 100, + }); + }); + + it('focus-within state matches snapshot', async ({ page }) => { + await page.goto(getStoryUrl('forms-label--default', 'light'), { + waitUntil: 'networkidle', + }); + const harness = page.locator(harnessLocator).first(); + await expect(harness).toBeVisible({ timeout: 10000 }); + const input = harness.locator('input').first(); + await input.focus(); + await page.waitForTimeout(100); + await expect(harness).toHaveScreenshot('label-focus-within-light.png', { + maxDiffPixels: 100, + }); + }); + }); + }); + + describe('Dark Theme (System prefers-color-scheme)', () => { + use({ colorScheme: 'dark' }); + + describe('Variants', () => { + it('default matches snapshot', async ({ page }) => { + await page.goto(getStoryUrl('forms-label--default'), { + waitUntil: 'networkidle', + }); + const harness = page.locator(harnessLocator).first(); + await expect(harness).toBeVisible({ timeout: 10000 }); + await expect(harness).toHaveScreenshot('label-default-dark.png', { + maxDiffPixels: 100, + }); + }); + + it('disabled matches snapshot', async ({ page }) => { + await page.goto(getStoryUrl('forms-label--disabled'), { + waitUntil: 'networkidle', + }); + const harness = page.locator(harnessLocator).first(); + await expect(harness).toBeVisible({ timeout: 10000 }); + await expect(harness).toHaveScreenshot('label-disabled-dark.png', { + maxDiffPixels: 100, + }); + }); + + it('error matches snapshot', async ({ page }) => { + await page.goto(getStoryUrl('forms-label--error'), { + waitUntil: 'networkidle', + }); + const harness = page.locator(harnessLocator).first(); + await expect(harness).toBeVisible({ timeout: 10000 }); + await expect(harness).toHaveScreenshot('label-error-dark.png', { + maxDiffPixels: 100, + }); + }); + }); + + describe('Interactive States', () => { + it('hover state matches snapshot', async ({ page }) => { + await page.goto(getStoryUrl('forms-label--default'), { + waitUntil: 'networkidle', + }); + const harness = page.locator(harnessLocator).first(); + await expect(harness).toBeVisible({ timeout: 10000 }); + const label = harness.locator('label').first(); + await label.hover(); + await page.waitForTimeout(100); + await expect(harness).toHaveScreenshot('label-hover-dark.png', { + maxDiffPixels: 100, + }); + }); + + it('focus-within state matches snapshot', async ({ page }) => { + await page.goto(getStoryUrl('forms-label--default'), { + waitUntil: 'networkidle', + }); + const harness = page.locator(harnessLocator).first(); + await expect(harness).toBeVisible({ timeout: 10000 }); + const input = harness.locator('input').first(); + await input.focus(); + await page.waitForTimeout(100); + await expect(harness).toHaveScreenshot('label-focus-within-dark.png', { + maxDiffPixels: 100, + }); + }); + }); + }); +}); diff --git a/tests/display/label.spec.ts-snapshots/label-default-dark-chromium-linux.png b/tests/display/label.spec.ts-snapshots/label-default-dark-chromium-linux.png new file mode 100644 index 000000000..58d0a7227 Binary files /dev/null and b/tests/display/label.spec.ts-snapshots/label-default-dark-chromium-linux.png differ diff --git a/tests/display/label.spec.ts-snapshots/label-default-light-chromium-linux.png b/tests/display/label.spec.ts-snapshots/label-default-light-chromium-linux.png new file mode 100644 index 000000000..2f86c1c0b Binary files /dev/null and b/tests/display/label.spec.ts-snapshots/label-default-light-chromium-linux.png differ diff --git a/tests/display/label.spec.ts-snapshots/label-disabled-dark-chromium-linux.png b/tests/display/label.spec.ts-snapshots/label-disabled-dark-chromium-linux.png new file mode 100644 index 000000000..23b6ae261 Binary files /dev/null and b/tests/display/label.spec.ts-snapshots/label-disabled-dark-chromium-linux.png differ diff --git a/tests/display/label.spec.ts-snapshots/label-disabled-light-chromium-linux.png b/tests/display/label.spec.ts-snapshots/label-disabled-light-chromium-linux.png new file mode 100644 index 000000000..4f70dd608 Binary files /dev/null and b/tests/display/label.spec.ts-snapshots/label-disabled-light-chromium-linux.png differ diff --git a/tests/display/label.spec.ts-snapshots/label-error-dark-chromium-linux.png b/tests/display/label.spec.ts-snapshots/label-error-dark-chromium-linux.png new file mode 100644 index 000000000..ae0c93c20 Binary files /dev/null and b/tests/display/label.spec.ts-snapshots/label-error-dark-chromium-linux.png differ diff --git a/tests/display/label.spec.ts-snapshots/label-error-light-chromium-linux.png b/tests/display/label.spec.ts-snapshots/label-error-light-chromium-linux.png new file mode 100644 index 000000000..f938ef5df Binary files /dev/null and b/tests/display/label.spec.ts-snapshots/label-error-light-chromium-linux.png differ diff --git a/tests/display/label.spec.ts-snapshots/label-focus-within-dark-chromium-linux.png b/tests/display/label.spec.ts-snapshots/label-focus-within-dark-chromium-linux.png new file mode 100644 index 000000000..273805b73 Binary files /dev/null and b/tests/display/label.spec.ts-snapshots/label-focus-within-dark-chromium-linux.png differ diff --git a/tests/display/label.spec.ts-snapshots/label-focus-within-light-chromium-linux.png b/tests/display/label.spec.ts-snapshots/label-focus-within-light-chromium-linux.png new file mode 100644 index 000000000..70c3717fd Binary files /dev/null and b/tests/display/label.spec.ts-snapshots/label-focus-within-light-chromium-linux.png differ diff --git a/tests/display/label.spec.ts-snapshots/label-hover-dark-chromium-linux.png b/tests/display/label.spec.ts-snapshots/label-hover-dark-chromium-linux.png new file mode 100644 index 000000000..390228b04 Binary files /dev/null and b/tests/display/label.spec.ts-snapshots/label-hover-dark-chromium-linux.png differ diff --git a/tests/display/label.spec.ts-snapshots/label-hover-light-chromium-linux.png b/tests/display/label.spec.ts-snapshots/label-hover-light-chromium-linux.png new file mode 100644 index 000000000..da0f29b94 Binary files /dev/null and b/tests/display/label.spec.ts-snapshots/label-hover-light-chromium-linux.png differ