diff --git a/.changeset/migrate-splitbutton-css-modules.md b/.changeset/migrate-splitbutton-css-modules.md new file mode 100644 index 000000000..2edc1def5 --- /dev/null +++ b/.changeset/migrate-splitbutton-css-modules.md @@ -0,0 +1,17 @@ +--- +'@clickhouse/click-ui': patch +--- + +🔄 **SplitButton Component Migration to CSS Modules** + +The `SplitButton` component has been migrated from Styled Components to CSS Modules (Web Standards + BEM class name convention). + +**Changes:** +- Migrated from `styled-components` to CSS Modules (`SplitButton.module.css`) +- Using BEM naming convention (`.split-button`, `.primary-button`, `.secondary-button`, etc.) +- Maintained full API compatibility - no breaking changes +- Both `primary` and `secondary` variants preserved +- Interactive states (hover, focus, disabled) maintained with CSS pseudo-classes + +**Visual Testing:** +This migration is protected by visual regression tests covering all button types and states in both light and dark themes. diff --git a/.storybook/main.ts b/.storybook/main.ts index 3af48cc27..cbe9297e1 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -1,25 +1,27 @@ -import type { StorybookConfig } from "@storybook/react-vite"; +import type { StorybookConfig } from '@storybook/react-vite'; const config: StorybookConfig = { core: { - disableTelemetry: true + disableTelemetry: true, }, - stories: [ - "./Introduction.mdx", - "../src/**/*.stories.@(ts|tsx)", - ], + stories: ['./Introduction.mdx', '../src/**/*.stories.@(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", + name: '@storybook/react-vite', options: {}, }, - staticDirs: ["../public"], + staticDirs: ['../public'], typescript: { - reactDocgen: "react-docgen-typescript", + reactDocgen: 'react-docgen-typescript', reactDocgenTypescriptOptions: { + tsconfigPath: '../tsconfig.json', compilerOptions: { allowSyntheticDefaultImports: false, esModuleInterop: false, @@ -32,8 +34,9 @@ const config: StorybookConfig = { }, async viteFinal(config, { configType }) { - config.plugins = (config.plugins || []).filter((plugin) => { - const pluginName = plugin && typeof plugin === 'object' && 'name' in plugin ? plugin.name : null; + config.plugins = (config.plugins || []).filter(plugin => { + const pluginName = + plugin && typeof plugin === 'object' && 'name' in plugin ? plugin.name : null; return pluginName !== 'css-external'; }); config.plugins.push({ diff --git a/src/components/SplitButton/SplitButton.module.css b/src/components/SplitButton/SplitButton.module.css new file mode 100644 index 000000000..e922a86f9 --- /dev/null +++ b/src/components/SplitButton/SplitButton.module.css @@ -0,0 +1,174 @@ +.split-button { + display: inline-flex; + overflow: hidden; + align-items: center; + border: 1px solid transparent; + border-radius: var(--click-button-radii-all); + user-select: none; +} + +.split-button_fill-width { + width: 100%; +} + +.split-button_primary { + border-color: var(--click-button-split-primary-stroke-default); +} + +.split-button_primary:hover:not([data-disabled]) { + border-color: var(--click-button-split-primary-stroke-hover); +} + +.split-button_primary:focus-within:not([data-disabled]) { + border-color: var(--click-button-split-primary-stroke-active); +} + +.split-button_primary[data-disabled] { + border-color: var(--click-button-split-primary-stroke-disabled); + cursor: not-allowed; +} + +.split-button_secondary { + border-color: var(--click-button-split-secondary-stroke-default); +} + +.split-button_secondary:hover:not([data-disabled]) { + border-color: var(--click-button-split-secondary-stroke-hover); +} + +.split-button_secondary:focus-within:not([data-disabled]) { + border-color: var(--click-button-split-secondary-stroke-active); +} + +.split-button_secondary[data-disabled] { + border-color: var(--click-button-split-secondary-stroke-disabled); + cursor: not-allowed; +} + +/* Override BaseButton's styled-component border-radius until BaseButton is migrated to CSS modules */ +.split-button .split-button__primary-button { + display: flex; + padding: var(--click-button-split-space-y) var(--click-button-split-space-x); + justify-content: center; + align-items: center; + align-self: stretch; + border: none; + border-radius: 0; + background: transparent; + color: inherit; + font: var(--click-button-split-typography-label-default); + cursor: pointer; +} + +.split-button .split-button__primary-button_fill-width { + width: 100%; +} + +.split-button .split-button__primary-button_primary { + background: var(--click-button-split-primary-background-main-default); + color: var(--click-button-split-primary-text-default); +} + +.split-button .split-button__primary-button_primary[data-disabled] { + background: var(--click-button-split-primary-background-main-disabled); + color: var(--click-button-split-primary-text-disabled); + font: var(--click-button-split-typography-label-disabled); + cursor: not-allowed; +} + +.split-button .split-button__primary-button_primary:hover:not([data-disabled]) { + background: var(--click-button-split-primary-background-main-hover); + color: var(--click-button-split-primary-text-hover); + font: var(--click-button-split-typography-label-hover); +} + +.split-button .split-button__primary-button_primary:focus-visible:not([data-disabled]) { + background: var(--click-button-split-primary-background-main-active); + color: var(--click-button-split-primary-text-active); + font: var(--click-button-split-typography-label-active); + outline: 2px solid var(--click-button-split-primary-stroke-active); + outline-offset: 2px; +} + +.split-button .split-button__primary-button_secondary { + background: var(--click-button-split-secondary-background-main-default); + color: var(--click-button-split-secondary-text-default); +} + +.split-button .split-button__primary-button_secondary[data-disabled] { + background: var(--click-button-split-secondary-background-main-disabled); + color: var(--click-button-split-secondary-text-disabled); + font: var(--click-button-split-typography-label-disabled); + cursor: not-allowed; +} + +.split-button .split-button__primary-button_secondary:hover:not([data-disabled]) { + background: var(--click-button-split-secondary-background-main-hover); + color: var(--click-button-split-secondary-text-hover); + font: var(--click-button-split-typography-label-hover); +} + +.split-button .split-button__primary-button_secondary:focus-visible:not([data-disabled]) { + background: var(--click-button-split-secondary-background-main-active); + color: var(--click-button-split-secondary-text-active); + font: var(--click-button-split-typography-label-active); + outline: 2px solid var(--click-button-split-secondary-stroke-active); + outline-offset: 2px; +} + +.split-button .split-button__secondary-button { + display: flex; + padding: var(--click-button-split-icon-space-y) var(--click-button-split-icon-space-x); + align-self: stretch; + border: none; + border-radius: 0; + background: transparent; + color: inherit; + cursor: pointer; +} + +.split-button .split-button__secondary-button_primary { + background: var(--click-button-split-primary-background-action-default); + color: var(--click-button-split-primary-text-default); +} + +.split-button .split-button__secondary-button_primary:hover:not([data-disabled]) { + background: var(--click-button-split-primary-background-action-hover); + color: var(--click-button-split-primary-text-hover); +} + +.split-button .split-button__secondary-button_primary:focus-visible:not([data-disabled]) { + background: var(--click-button-split-primary-background-action-active); + color: var(--click-button-split-primary-text-active); + outline: 2px solid var(--click-button-split-primary-stroke-active); + outline-offset: 2px; +} + +.split-button .split-button__secondary-button_primary[data-disabled] { + background: var(--click-button-split-primary-background-action-disabled); + color: var(--click-button-split-primary-text-disabled); + cursor: not-allowed; +} + +.split-button .split-button__secondary-button_secondary { + background: var(--click-button-split-secondary-background-action-default); + color: var(--click-button-split-secondary-text-default); +} + +.split-button .split-button__secondary-button_secondary:hover:not([data-disabled]) { + background: var(--click-button-split-secondary-background-action-hover); + color: var(--click-button-split-secondary-text-hover); +} + +.split-button .split-button__secondary-button_secondary:focus-visible:not([data-disabled]) { + background: var(--click-button-split-secondary-background-action-active); + color: var(--click-button-split-secondary-text-active); + outline: 2px solid var(--click-button-split-secondary-stroke-active); + outline-offset: 2px; +} + +.split-button .split-button__secondary-button_secondary[data-disabled] { + background: var(--click-button-split-secondary-background-action-disabled); + color: var(--click-button-split-secondary-text-disabled); + cursor: not-allowed; +} diff --git a/src/components/SplitButton/SplitButton.stories.tsx b/src/components/SplitButton/SplitButton.stories.tsx index a8ac1457a..8d07eb5c9 100644 --- a/src/components/SplitButton/SplitButton.stories.tsx +++ b/src/components/SplitButton/SplitButton.stories.tsx @@ -61,3 +61,62 @@ export const Playground: Story = { menu: menuItems, }, }; + +// Button Types +export const Primary: Story = { + args: { + type: 'primary', + children: 'Primary Split Button', + menu: menuItems, + }, +}; + +export const Secondary: Story = { + args: { + type: 'secondary', + children: 'Secondary Split Button', + menu: menuItems, + }, +}; + +// Disabled States +export const PrimaryDisabled: Story = { + args: { + type: 'primary', + children: 'Disabled Primary', + menu: menuItems, + disabled: true, + }, +}; + +export const SecondaryDisabled: Story = { + args: { + type: 'secondary', + children: 'Disabled Secondary', + menu: menuItems, + disabled: true, + }, +}; + +// Interactive +export const Interactive: Story = { + args: { + type: 'primary', + children: 'Interactive Split Button', + menu: menuItems, + onClick: () => console.log('clicked'), + }, +}; + +// Layout Variants +export const FillWidth: Story = { + args: { + type: 'primary', + children: 'Full Width Split Button', + menu: menuItems, + fillWidth: true, + }, + parameters: { + layout: 'padded', + }, +}; diff --git a/src/components/SplitButton/SplitButton.tsx b/src/components/SplitButton/SplitButton.tsx index 8516ff6a1..196332593 100644 --- a/src/components/SplitButton/SplitButton.tsx +++ b/src/components/SplitButton/SplitButton.tsx @@ -1,114 +1,172 @@ -import { useEffect, useRef, useState } from 'react'; -import { styled } from 'styled-components'; +import { useEffect, useRef, useState, forwardRef } from 'react'; +import { cn, cva } from '@/lib/cva'; import { Dropdown } from '@/components/Dropdown'; import { BaseButton } from '@/components/Button/BaseButton'; import { IconWrapper } from '@/components/IconWrapper'; import { Icon } from '@/components/Icon'; -import { SplitButtonProps, Menu, ButtonType } from './SplitButton.types'; +import { SplitButtonProps, Menu } from './SplitButton.types'; +import styles from './SplitButton.module.css'; -export const SplitButton = ({ - type = 'primary', - disabled, - menu, - dir, - open, - defaultOpen, - onOpenChange, - modal, - side, - fillWidth, - children, - icon, - iconDir = 'start', - ...props -}: SplitButtonProps) => { - const ref = useRef(null); - const [width, setWidth] = useState(0); +const splitButtonVariants = cva(styles['split-button'], { + variants: { + type: { + primary: styles['split-button_primary'], + secondary: styles['split-button_secondary'], + }, + fillWidth: { + true: styles['split-button_fill-width'], + }, + }, + defaultVariants: { + type: 'primary', + }, +}); + +const primaryButtonVariants = cva(styles['split-button__primary-button'], { + variants: { + type: { + primary: styles['split-button__primary-button_primary'], + secondary: styles['split-button__primary-button_secondary'], + }, + fillWidth: { + true: styles['split-button__primary-button_fill-width'], + }, + }, + defaultVariants: { + type: 'primary', + }, +}); - useEffect(() => { - const targetDiv = ref.current; - if (!targetDiv) { - return; - } +const secondaryButtonVariants = cva(styles['split-button__secondary-button'], { + variants: { + type: { + primary: styles['split-button__secondary-button_primary'], + secondary: styles['split-button__secondary-button_secondary'], + }, + }, + defaultVariants: { + type: 'primary', + }, +}); - const resizeObserver = new ResizeObserver(entries => { - for (const entry of entries) { - setWidth(entry.target.clientWidth); +export const SplitButton = forwardRef( + ( + { + type = 'primary', + disabled, + menu, + dir, + open, + defaultOpen, + onOpenChange, + modal, + side, + fillWidth, + children, + icon, + iconDir = 'start', + className, + ...props + }, + forwardedRef + ) => { + const wrapperRef = useRef(null); + const [width, setWidth] = useState(0); + + useEffect(() => { + const targetDiv = wrapperRef.current; + if (!targetDiv) { + return; } - }); - resizeObserver.observe(targetDiv); + const resizeObserver = new ResizeObserver(entries => { + for (const entry of entries) { + setWidth(entry.target.clientWidth); + } + }); + + resizeObserver.observe(targetDiv); - return () => { - resizeObserver.unobserve(targetDiv); - }; - }, []); + return () => { + resizeObserver.unobserve(targetDiv); + }; + }, []); - return ( - - - - + + {children} + + + - {children} - - - + + + + + + + - - ( + - - - - - {menu.map((item: Menu, index: number) => ( - - ))} - - - ); -}; + ))} + + + ); + } +); -const DropdownContent = styled.div<{ $width: number }>` - min-width: ${({ $width }) => $width}px; -`; +SplitButton.displayName = 'SplitButton'; const MenuContentItem = ({ items = [], @@ -171,95 +229,3 @@ const MenuContentItem = ({ ); } }; - -const SplitButtonTrigger = styled.div<{ - $disabled?: boolean; - $type: ButtonType; - $fillWidth?: boolean; -}>` - display: inline-flex; - align-items: center; - overflow: hidden; - user-select: none; - ${({ theme, $disabled = false, $type, $fillWidth }) => ` - width: ${$fillWidth ? '100%' : 'revert'}; - border-radius: ${theme.click.button.radii.all}; - border: 1px solid ${theme.click.button.split[$type].stroke.default}; - ${ - $disabled - ? ` - cursor: not-allowed; - border-color: ${theme.click.button.split[$type].stroke.disabled}; - ` - : ` - &:hover { - border-color: ${theme.click.button.split[$type].stroke.hover}; - } - &:focus-within { - border-color: ${theme.click.button.split[$type].stroke.active}; - } - ` - } - `} -`; - -const PrimaryButton = styled(BaseButton)<{ - $type: ButtonType; - $fillWidth?: boolean; -}>` - border: none; - align-self: stretch; - border-radius: 0; - align-items: center; - padding: ${({ theme }) => - `${theme.click.button.split.space.y} ${theme.click.button.split.space.x}`}; - ${({ theme, $type, $fillWidth }) => ` - width: ${$fillWidth ? '100%' : 'revert'}; - justify-content: center; - background: ${theme.click.button.split[$type].background.main.default}; - color: ${theme.click.button.split[$type].text.default}; - font: ${theme.click.button.split.typography.label.default}; - &:hover { - background: ${theme.click.button.split[$type].background.main.hover}; - color: ${theme.click.button.split[$type].text.hover}; - font: ${theme.click.button.split.typography.label.hover}; - } - &:focus { - background: ${theme.click.button.split[$type].background.main.active}; - color: ${theme.click.button.split[$type].text.active}; - font: ${theme.click.button.split.typography.label.active}; - } - &:disabled { - background: ${theme.click.button.split[$type].background.main.disabled}; - color: ${theme.click.button.split[$type].text.disabled}; - font: ${theme.click.button.split.typography.label.disabled}; - } - `} -`; - -const SecondaryButton = styled(BaseButton)<{ $type: ButtonType }>` - border: none; - align-self: stretch; - border-radius: 0; - ${({ theme, $type }) => ` - padding: ${theme.click.button.split.icon.space.y} ${theme.click.button.split.icon.space.x}; - background: ${theme.click.button.split[$type].background.action.default}; - color: ${theme.click.button.split[$type].text.default}; - &:hover { - background: ${theme.click.button.split[$type].background.action.hover}; - color: ${theme.click.button.split[$type].text.hover}; - } - &:focus { - background: ${theme.click.button.split[$type].background.action.active}; - color: ${theme.click.button.split[$type].text.active}; - } - &[data-disabled] { - background: ${theme.click.button.split[$type].background.action.disabled}; - color: ${theme.click.button.split[$type].text.disabled}; - } - `} -`; - -const ButtonData = styled.div` - width: auto; -`; diff --git a/tests/buttons/splitbutton.spec.ts b/tests/buttons/splitbutton.spec.ts new file mode 100644 index 000000000..e8534259a --- /dev/null +++ b/tests/buttons/splitbutton.spec.ts @@ -0,0 +1,318 @@ +import { test as it, expect } from '@playwright/test'; +import { getStoryUrl } from '../utils'; + +const { describe, use } = it; + +describe('SplitButton Visual Regression', () => { + describe('Light Theme (Storybook Global)', () => { + describe('Button Types', () => { + it('primary button matches snapshot', async ({ page }) => { + await page.goto(getStoryUrl('buttons-splitbutton--primary', 'light'), { + waitUntil: 'networkidle', + }); + const splitButton = page.locator('[data-testid="split-button"]'); + await expect(splitButton).toBeVisible({ timeout: 10000 }); + await expect(splitButton).toHaveScreenshot('splitbutton-primary-light.png', { + maxDiffPixels: 100, + }); + }); + + it('secondary button matches snapshot', async ({ page }) => { + await page.goto(getStoryUrl('buttons-splitbutton--secondary', 'light'), { + waitUntil: 'networkidle', + }); + const splitButton = page.locator('[data-testid="split-button"]'); + await expect(splitButton).toBeVisible({ timeout: 10000 }); + await expect(splitButton).toHaveScreenshot('splitbutton-secondary-light.png', { + maxDiffPixels: 100, + }); + }); + }); + + describe('Disabled States', () => { + it('primary disabled matches snapshot', async ({ page }) => { + await page.goto(getStoryUrl('buttons-splitbutton--primary-disabled', 'light'), { + waitUntil: 'networkidle', + }); + const splitButton = page.locator('[data-testid="split-button"]'); + const primaryButton = page.getByRole('button').first(); + const dropdownTrigger = page.locator('[data-testid="split-button-dropdown"]'); + await expect(splitButton).toBeVisible({ timeout: 10000 }); + await expect(primaryButton).toHaveAttribute('aria-disabled', 'true'); + await expect(dropdownTrigger).toHaveAttribute('aria-disabled', 'true'); + await expect(splitButton).toHaveScreenshot( + 'splitbutton-primary-disabled-light.png', + { + maxDiffPixels: 100, + } + ); + }); + + it('secondary disabled matches snapshot', async ({ page }) => { + await page.goto(getStoryUrl('buttons-splitbutton--secondary-disabled', 'light'), { + waitUntil: 'networkidle', + }); + const splitButton = page.locator('[data-testid="split-button"]'); + const primaryButton = page.getByRole('button').first(); + const dropdownTrigger = page.locator('[data-testid="split-button-dropdown"]'); + await expect(splitButton).toBeVisible({ timeout: 10000 }); + await expect(primaryButton).toHaveAttribute('aria-disabled', 'true'); + await expect(dropdownTrigger).toHaveAttribute('aria-disabled', 'true'); + await expect(splitButton).toHaveScreenshot( + 'splitbutton-secondary-disabled-light.png', + { + maxDiffPixels: 100, + } + ); + }); + }); + + describe('Interactive States', () => { + it('hover state - primary', async ({ page }) => { + await page.goto(getStoryUrl('buttons-splitbutton--primary', 'light'), { + waitUntil: 'networkidle', + }); + const splitButton = page.locator('[data-testid="split-button"]'); + await expect(splitButton).toBeVisible({ timeout: 10000 }); + await splitButton.hover(); + await page.waitForTimeout(100); + await expect(splitButton).toHaveScreenshot( + 'splitbutton-primary-hover-light.png', + { + maxDiffPixels: 100, + } + ); + }); + + it('focus state - primary', async ({ page }) => { + await page.goto(getStoryUrl('buttons-splitbutton--primary', 'light'), { + waitUntil: 'networkidle', + }); + const splitButton = page.locator('[data-testid="split-button"]'); + await expect(splitButton).toBeVisible({ timeout: 10000 }); + // Focus the primary button inside the split button + const primaryButton = page.getByRole('button').first(); + await primaryButton.focus(); + await page.waitForTimeout(100); + await expect(splitButton).toHaveScreenshot( + 'splitbutton-primary-focus-light.png', + { + maxDiffPixels: 100, + } + ); + }); + + it('hover state - secondary', async ({ page }) => { + await page.goto(getStoryUrl('buttons-splitbutton--secondary', 'light'), { + waitUntil: 'networkidle', + }); + const splitButton = page.locator('[data-testid="split-button"]'); + await expect(splitButton).toBeVisible({ timeout: 10000 }); + await splitButton.hover(); + await page.waitForTimeout(100); + await expect(splitButton).toHaveScreenshot( + 'splitbutton-secondary-hover-light.png', + { + maxDiffPixels: 100, + } + ); + }); + + it('focus state - secondary', async ({ page }) => { + await page.goto(getStoryUrl('buttons-splitbutton--secondary', 'light'), { + waitUntil: 'networkidle', + }); + const splitButton = page.locator('[data-testid="split-button"]'); + await expect(splitButton).toBeVisible({ timeout: 10000 }); + // Focus the primary button inside the split button + const primaryButton = page.getByRole('button').first(); + await primaryButton.focus(); + await page.waitForTimeout(100); + await expect(splitButton).toHaveScreenshot( + 'splitbutton-secondary-focus-light.png', + { + maxDiffPixels: 100, + } + ); + }); + }); + }); + + describe('Dark Theme (System prefers-color-scheme)', () => { + use({ colorScheme: 'dark' }); + + describe('Button Types', () => { + it('primary button matches snapshot', async ({ page }) => { + await page.goto(getStoryUrl('buttons-splitbutton--primary'), { + waitUntil: 'networkidle', + }); + const splitButton = page.locator('[data-testid="split-button"]'); + await expect(splitButton).toBeVisible({ timeout: 10000 }); + await expect(splitButton).toHaveScreenshot('splitbutton-primary-dark.png', { + maxDiffPixels: 100, + }); + }); + + it('secondary button matches snapshot', async ({ page }) => { + await page.goto(getStoryUrl('buttons-splitbutton--secondary'), { + waitUntil: 'networkidle', + }); + const splitButton = page.locator('[data-testid="split-button"]'); + await expect(splitButton).toBeVisible({ timeout: 10000 }); + await expect(splitButton).toHaveScreenshot('splitbutton-secondary-dark.png', { + maxDiffPixels: 100, + }); + }); + }); + + describe('Disabled States', () => { + it('primary disabled matches snapshot', async ({ page }) => { + await page.goto(getStoryUrl('buttons-splitbutton--primary-disabled'), { + waitUntil: 'networkidle', + }); + const splitButton = page.locator('[data-testid="split-button"]'); + const primaryButton = page.getByRole('button').first(); + const dropdownTrigger = page.locator('[data-testid="split-button-dropdown"]'); + await expect(splitButton).toBeVisible({ timeout: 10000 }); + await expect(primaryButton).toHaveAttribute('aria-disabled', 'true'); + await expect(dropdownTrigger).toHaveAttribute('aria-disabled', 'true'); + await expect(splitButton).toHaveScreenshot( + 'splitbutton-primary-disabled-dark.png', + { + maxDiffPixels: 100, + } + ); + }); + + it('secondary disabled matches snapshot', async ({ page }) => { + await page.goto(getStoryUrl('buttons-splitbutton--secondary-disabled'), { + waitUntil: 'networkidle', + }); + const splitButton = page.locator('[data-testid="split-button"]'); + const primaryButton = page.getByRole('button').first(); + const dropdownTrigger = page.locator('[data-testid="split-button-dropdown"]'); + await expect(splitButton).toBeVisible({ timeout: 10000 }); + await expect(primaryButton).toHaveAttribute('aria-disabled', 'true'); + await expect(dropdownTrigger).toHaveAttribute('aria-disabled', 'true'); + await expect(splitButton).toHaveScreenshot( + 'splitbutton-secondary-disabled-dark.png', + { + maxDiffPixels: 100, + } + ); + }); + }); + + describe('Interactive States', () => { + it('hover state - primary', async ({ page }) => { + await page.goto(getStoryUrl('buttons-splitbutton--primary'), { + waitUntil: 'networkidle', + }); + const splitButton = page.locator('[data-testid="split-button"]'); + await expect(splitButton).toBeVisible({ timeout: 10000 }); + await splitButton.hover(); + await page.waitForTimeout(100); + await expect(splitButton).toHaveScreenshot('splitbutton-primary-hover-dark.png', { + maxDiffPixels: 100, + }); + }); + + it('focus state - primary', async ({ page }) => { + await page.goto(getStoryUrl('buttons-splitbutton--primary'), { + waitUntil: 'networkidle', + }); + const splitButton = page.locator('[data-testid="split-button"]'); + await expect(splitButton).toBeVisible({ timeout: 10000 }); + // Focus the primary button inside the split button + const primaryButton = page.getByRole('button').first(); + await primaryButton.focus(); + await page.waitForTimeout(100); + await expect(splitButton).toHaveScreenshot('splitbutton-primary-focus-dark.png', { + maxDiffPixels: 100, + }); + }); + + it('hover state - secondary', async ({ page }) => { + await page.goto(getStoryUrl('buttons-splitbutton--secondary'), { + waitUntil: 'networkidle', + }); + const splitButton = page.locator('[data-testid="split-button"]'); + await expect(splitButton).toBeVisible({ timeout: 10000 }); + await splitButton.hover(); + await page.waitForTimeout(100); + await expect(splitButton).toHaveScreenshot( + 'splitbutton-secondary-hover-dark.png', + { + maxDiffPixels: 100, + } + ); + }); + + it('focus state - secondary', async ({ page }) => { + await page.goto(getStoryUrl('buttons-splitbutton--secondary'), { + waitUntil: 'networkidle', + }); + const splitButton = page.locator('[data-testid="split-button"]'); + await expect(splitButton).toBeVisible({ timeout: 10000 }); + // Focus the primary button inside the split button + const primaryButton = page.getByRole('button').first(); + await primaryButton.focus(); + await page.waitForTimeout(100); + await expect(splitButton).toHaveScreenshot( + 'splitbutton-secondary-focus-dark.png', + { + maxDiffPixels: 100, + } + ); + }); + }); + }); + + describe('Events and Accessibility', () => { + it('click event fires correctly', async ({ page }) => { + const consoleMessages: string[] = []; + page.on('console', msg => consoleMessages.push(msg.text())); + + await page.goto(getStoryUrl('buttons-splitbutton--interactive', 'light'), { + waitUntil: 'networkidle', + }); + const primaryButton = page.getByRole('button').first(); + await expect(primaryButton).toBeVisible({ timeout: 10000 }); + await expect(primaryButton).toBeEnabled(); + + await primaryButton.click(); + + // Verify console log was triggered + expect(consoleMessages.some(msg => msg.includes('clicked'))).toBe(true); + }); + + it('disabled button prevents click', async ({ page }) => { + await page.goto(getStoryUrl('buttons-splitbutton--primary-disabled', 'light'), { + waitUntil: 'networkidle', + }); + const primaryButton = page.getByRole('button').first(); + const dropdownTrigger = page.locator('[data-testid="split-button-dropdown"]'); + await expect(primaryButton).toBeDisabled(); + await expect(primaryButton).toHaveAttribute('aria-disabled', 'true'); + await expect(dropdownTrigger).toHaveAttribute('aria-disabled', 'true'); + }); + + it('keyboard navigation works', async ({ page }) => { + const consoleMessages: string[] = []; + page.on('console', msg => consoleMessages.push(msg.text())); + + await page.goto(getStoryUrl('buttons-splitbutton--interactive', 'light'), { + waitUntil: 'networkidle', + }); + const primaryButton = page.getByRole('button').first(); + await expect(primaryButton).toBeVisible({ timeout: 10000 }); + + await primaryButton.focus(); + await expect(primaryButton).toBeFocused(); + await primaryButton.press('Enter'); + + // Verify console log was triggered + expect(consoleMessages.some(msg => msg.includes('clicked'))).toBe(true); + }); + }); +}); diff --git a/tests/buttons/splitbutton.spec.ts-snapshots/splitbutton-primary-dark-chromium-linux.png b/tests/buttons/splitbutton.spec.ts-snapshots/splitbutton-primary-dark-chromium-linux.png new file mode 100644 index 000000000..8815f2599 Binary files /dev/null and b/tests/buttons/splitbutton.spec.ts-snapshots/splitbutton-primary-dark-chromium-linux.png differ diff --git a/tests/buttons/splitbutton.spec.ts-snapshots/splitbutton-primary-disabled-dark-chromium-linux.png b/tests/buttons/splitbutton.spec.ts-snapshots/splitbutton-primary-disabled-dark-chromium-linux.png new file mode 100644 index 000000000..e4096742b Binary files /dev/null and b/tests/buttons/splitbutton.spec.ts-snapshots/splitbutton-primary-disabled-dark-chromium-linux.png differ diff --git a/tests/buttons/splitbutton.spec.ts-snapshots/splitbutton-primary-disabled-light-chromium-linux.png b/tests/buttons/splitbutton.spec.ts-snapshots/splitbutton-primary-disabled-light-chromium-linux.png new file mode 100644 index 000000000..ccbb4fa1e Binary files /dev/null and b/tests/buttons/splitbutton.spec.ts-snapshots/splitbutton-primary-disabled-light-chromium-linux.png differ diff --git a/tests/buttons/splitbutton.spec.ts-snapshots/splitbutton-primary-focus-dark-chromium-linux.png b/tests/buttons/splitbutton.spec.ts-snapshots/splitbutton-primary-focus-dark-chromium-linux.png new file mode 100644 index 000000000..42c12493d Binary files /dev/null and b/tests/buttons/splitbutton.spec.ts-snapshots/splitbutton-primary-focus-dark-chromium-linux.png differ diff --git a/tests/buttons/splitbutton.spec.ts-snapshots/splitbutton-primary-focus-light-chromium-linux.png b/tests/buttons/splitbutton.spec.ts-snapshots/splitbutton-primary-focus-light-chromium-linux.png new file mode 100644 index 000000000..1bbe7c434 Binary files /dev/null and b/tests/buttons/splitbutton.spec.ts-snapshots/splitbutton-primary-focus-light-chromium-linux.png differ diff --git a/tests/buttons/splitbutton.spec.ts-snapshots/splitbutton-primary-hover-dark-chromium-linux.png b/tests/buttons/splitbutton.spec.ts-snapshots/splitbutton-primary-hover-dark-chromium-linux.png new file mode 100644 index 000000000..de5365f85 Binary files /dev/null and b/tests/buttons/splitbutton.spec.ts-snapshots/splitbutton-primary-hover-dark-chromium-linux.png differ diff --git a/tests/buttons/splitbutton.spec.ts-snapshots/splitbutton-primary-hover-light-chromium-linux.png b/tests/buttons/splitbutton.spec.ts-snapshots/splitbutton-primary-hover-light-chromium-linux.png new file mode 100644 index 000000000..1026de982 Binary files /dev/null and b/tests/buttons/splitbutton.spec.ts-snapshots/splitbutton-primary-hover-light-chromium-linux.png differ diff --git a/tests/buttons/splitbutton.spec.ts-snapshots/splitbutton-primary-light-chromium-linux.png b/tests/buttons/splitbutton.spec.ts-snapshots/splitbutton-primary-light-chromium-linux.png new file mode 100644 index 000000000..5e499c19d Binary files /dev/null and b/tests/buttons/splitbutton.spec.ts-snapshots/splitbutton-primary-light-chromium-linux.png differ diff --git a/tests/buttons/splitbutton.spec.ts-snapshots/splitbutton-secondary-dark-chromium-linux.png b/tests/buttons/splitbutton.spec.ts-snapshots/splitbutton-secondary-dark-chromium-linux.png new file mode 100644 index 000000000..66331fde7 Binary files /dev/null and b/tests/buttons/splitbutton.spec.ts-snapshots/splitbutton-secondary-dark-chromium-linux.png differ diff --git a/tests/buttons/splitbutton.spec.ts-snapshots/splitbutton-secondary-disabled-dark-chromium-linux.png b/tests/buttons/splitbutton.spec.ts-snapshots/splitbutton-secondary-disabled-dark-chromium-linux.png new file mode 100644 index 000000000..7ce0a571b Binary files /dev/null and b/tests/buttons/splitbutton.spec.ts-snapshots/splitbutton-secondary-disabled-dark-chromium-linux.png differ diff --git a/tests/buttons/splitbutton.spec.ts-snapshots/splitbutton-secondary-disabled-light-chromium-linux.png b/tests/buttons/splitbutton.spec.ts-snapshots/splitbutton-secondary-disabled-light-chromium-linux.png new file mode 100644 index 000000000..f177e23ae Binary files /dev/null and b/tests/buttons/splitbutton.spec.ts-snapshots/splitbutton-secondary-disabled-light-chromium-linux.png differ diff --git a/tests/buttons/splitbutton.spec.ts-snapshots/splitbutton-secondary-focus-dark-chromium-linux.png b/tests/buttons/splitbutton.spec.ts-snapshots/splitbutton-secondary-focus-dark-chromium-linux.png new file mode 100644 index 000000000..a67759fac Binary files /dev/null and b/tests/buttons/splitbutton.spec.ts-snapshots/splitbutton-secondary-focus-dark-chromium-linux.png differ diff --git a/tests/buttons/splitbutton.spec.ts-snapshots/splitbutton-secondary-focus-light-chromium-linux.png b/tests/buttons/splitbutton.spec.ts-snapshots/splitbutton-secondary-focus-light-chromium-linux.png new file mode 100644 index 000000000..556433ca4 Binary files /dev/null and b/tests/buttons/splitbutton.spec.ts-snapshots/splitbutton-secondary-focus-light-chromium-linux.png differ diff --git a/tests/buttons/splitbutton.spec.ts-snapshots/splitbutton-secondary-hover-dark-chromium-linux.png b/tests/buttons/splitbutton.spec.ts-snapshots/splitbutton-secondary-hover-dark-chromium-linux.png new file mode 100644 index 000000000..90f9e5024 Binary files /dev/null and b/tests/buttons/splitbutton.spec.ts-snapshots/splitbutton-secondary-hover-dark-chromium-linux.png differ diff --git a/tests/buttons/splitbutton.spec.ts-snapshots/splitbutton-secondary-hover-light-chromium-linux.png b/tests/buttons/splitbutton.spec.ts-snapshots/splitbutton-secondary-hover-light-chromium-linux.png new file mode 100644 index 000000000..85f555254 Binary files /dev/null and b/tests/buttons/splitbutton.spec.ts-snapshots/splitbutton-secondary-hover-light-chromium-linux.png differ diff --git a/tests/buttons/splitbutton.spec.ts-snapshots/splitbutton-secondary-light-chromium-linux.png b/tests/buttons/splitbutton.spec.ts-snapshots/splitbutton-secondary-light-chromium-linux.png new file mode 100644 index 000000000..de1796e8e Binary files /dev/null and b/tests/buttons/splitbutton.spec.ts-snapshots/splitbutton-secondary-light-chromium-linux.png differ diff --git a/tsconfig.json b/tsconfig.json index 44f93e335..3d9d2f34e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,7 +24,7 @@ }, "types": ["@testing-library/jest-dom", "vitest/globals"] }, - "include": ["src"], + "include": ["src", ".storybook"], "exclude": [ "node_modules", "dist",