diff --git a/.changeset/has-selector-element-transformation.md b/.changeset/has-selector-element-transformation.md new file mode 100644 index 000000000..7f98ef9c7 --- /dev/null +++ b/.changeset/has-selector-element-transformation.md @@ -0,0 +1,5 @@ +--- +'@cube-dev/ui-kit': minor +--- + +Add support for `:has(Item)` syntax in style mappings. Capitalized element names inside `:has()` pseudo-class selectors are now automatically transformed to `data-element` attribute selectors (`:has(Item)` → `:has([data-element="Item"])`). diff --git a/.changeset/value-mods.md b/.changeset/value-mods.md new file mode 100644 index 000000000..ea0a6bd71 --- /dev/null +++ b/.changeset/value-mods.md @@ -0,0 +1,7 @@ +--- +'@cube-dev/ui-kit': major +--- + +**BREAKING:** Boolean mods now generate `data-*` instead of `data-is-*` attributes (`mods={{ hovered: true }}` → `data-hovered=""` instead of `data-is-hovered=""`). + +**NEW:** Value mods support - `mods` now accepts string values (`mods={{ theme: 'danger' }}` → `data-theme="danger"`). Includes shorthand syntax in styles (`theme=danger`, `theme="danger"`). See Tasty documentation for details. diff --git a/.gitignore b/.gitignore index 9bc882bcc..3e7f5e035 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ coverage size-limit-report/stats.json docs-static *.plan.md +*.spec.md diff --git a/.size-limit.cjs b/.size-limit.cjs index 74fa3cd51..3482f2adf 100644 --- a/.size-limit.cjs +++ b/.size-limit.cjs @@ -27,13 +27,13 @@ module.exports = [ path: './dist/es/index.js', webpack: true, import: '{ Button }', - limit: '33 kB', + limit: '35 kB', }, { name: 'Tree shaking (just an Icon)', path: './dist/es/index.js', webpack: true, import: '{ AiIcon }', - limit: '20 kB', + limit: '22 kB', }, ]; diff --git a/src/components/actions/Button/Button.docs.mdx b/src/components/actions/Button/Button.docs.mdx index 5a25e3276..c2363a0bd 100644 --- a/src/components/actions/Button/Button.docs.mdx +++ b/src/components/actions/Button/Button.docs.mdx @@ -54,7 +54,7 @@ The `mods` prop accepts the following modifiers you can override: | disabled | `boolean` | Forces disabled appearance. | | loading | `boolean` | Displays loading spinner. | | selected | `boolean` | Displays selected state. | -| with-icons | `boolean` | Indicates that the button contains at least one icon. | +| has-icons | `boolean` | Indicates that the button contains at least one icon. | | single-icon | `boolean` | Icon-only button without text. | ## Variants diff --git a/src/components/actions/Button/Button.tsx b/src/components/actions/Button/Button.tsx index f5476dbcd..9cec42749 100644 --- a/src/components/actions/Button/Button.tsx +++ b/src/components/actions/Button/Button.tsx @@ -105,12 +105,12 @@ export const DEFAULT_BUTTON_STYLES = { }, gap: { '': '.75x', - '[data-size="small"]': '.5x', + 'size=small': '.5x', }, preset: { '': 't3m', - '[data-size="xsmall"]': 't4', - '[data-size="xlarge"]': 't2m', + 'size=xsmall': 't4', + 'size=xlarge': 't2m', }, textDecoration: 'none', transition: 'theme', @@ -119,34 +119,34 @@ export const DEFAULT_BUTTON_STYLES = { outlineOffset: 1, padding: { '': '.5x (1.5x - 1bw)', - '[data-size="small"] | [data-size="xsmall"]': '.5x (1.25x - 1bw)', - '[data-size="medium"]': '.5x (1.5x - 1bw)', - '[data-size="large"]': '.5x (1.75x - 1bw)', - '[data-size="xlarge"]': '.5x (2x - 1bw)', - 'single-icon | [data-type="link"]': 0, + 'size=small | size=xsmall': '.5x (1.25x - 1bw)', + 'size=medium': '.5x (1.5x - 1bw)', + 'size=large': '.5x (1.75x - 1bw)', + 'size=xlarge': '.5x (2x - 1bw)', + 'single-icon | type=link': 0, }, width: { '': 'initial', - '[data-size="xsmall"] & single-icon': '$size-xs $size-xs', - '[data-size="small"] & single-icon': '$size-sm $size-sm', - '[data-size="medium"] & single-icon': '$size-md $size-md', - '[data-size="large"] & single-icon': '$size-lg $size-lg', - '[data-size="xlarge"] & single-icon': '$size-xl $size-xl', - '[data-type="link"]': 'initial', + 'size=xsmall & single-icon': '$size-xs $size-xs', + 'size=small & single-icon': '$size-sm $size-sm', + 'size=medium & single-icon': '$size-md $size-md', + 'size=large & single-icon': '$size-lg $size-lg', + 'size=xlarge & single-icon': '$size-xl $size-xl', + 'type=link': 'initial', }, height: { '': 'initial', - '[data-size="xsmall"]': '$size-xs $size-xs', - '[data-size="small"]': '$size-sm $size-sm', - '[data-size="medium"]': '$size-md $size-md', - '[data-size="large"]': '$size-lg $size-lg', - '[data-size="xlarge"]': '$size-xl $size-xl', - '[data-type="link"]': 'initial', + 'size=xsmall': '$size-xs $size-xs', + 'size=small': '$size-sm $size-sm', + 'size=medium': '$size-md $size-md', + 'size=large': '$size-lg $size-lg', + 'size=xlarge': '$size-xl $size-xl', + 'type=link': 'initial', }, whiteSpace: 'nowrap', radius: { '': true, - '[data-type="link"] & !focused': 0, + 'type=link & !focused': 0, }, ButtonIcon: { @@ -272,7 +272,7 @@ export const Button = forwardRef(function Button( () => ({ loading: isLoading, selected: isSelected, - 'with-icons': hasIcons, + 'has-icons': hasIcons, 'left-icon': !!icon, 'right-icon': !!rightIcon, 'single-icon': singleIcon, diff --git a/src/components/actions/Button/button.test.tsx b/src/components/actions/Button/button.test.tsx index 140b389a3..d4e115bc7 100644 --- a/src/components/actions/Button/button.test.tsx +++ b/src/components/actions/Button/button.test.tsx @@ -11,7 +11,7 @@ describe(' -// Renders: +// Renders: , + ); + + const button = container.firstChild as HTMLElement; + expect(button).toHaveAttribute('data-theme', 'danger'); + + rerender(); + expect(button).toHaveAttribute('data-theme', 'warning'); + }); + + it('should apply styles combining boolean and value mods', () => { + const Card = tasty({ + as: 'div', + styles: { + fill: { + '': '#surface', + hovered: '#surface-hover', + 'theme=danger': '#red', + 'hovered & theme=danger': '#light-red', + }, + }, + }); + + const { container } = render( + Content, + ); + + const card = container.firstChild as HTMLElement; + expect(card).toHaveAttribute('data-hovered', ''); + expect(card).toHaveAttribute('data-theme', 'danger'); + }); + + it('should support quoted value syntax', () => { + const Element = tasty({ + as: 'div', + styles: { + color: { + '': '#text', + 'variant="primary"': '#purple', + "variant='secondary'": '#gray', + }, + }, + }); + + const { container, rerender } = render( + Primary, + ); + + expect(container.firstChild).toHaveAttribute('data-variant', 'primary'); + + rerender(Secondary); + expect(container.firstChild).toHaveAttribute('data-variant', 'secondary'); + }); + + it('should support full attribute selector syntax', () => { + const Element = tasty({ + as: 'div', + styles: { + color: { + '': '#text', + '[data-theme="danger"]': '#red', + }, + }, + }); + + const { container } = render( + Danger, + ); + + expect(container.firstChild).toHaveAttribute('data-theme', 'danger'); + }); + }); + + describe('Priority order for same-attribute selectors', () => { + it('should respect first-match priority when boolean has higher priority than value', () => { + const Component = tasty({ + as: 'div', + styles: { + color: { + 'theme=danger': 'red', + theme: 'blue', // Higher priority after reversal + }, + }, + }); + + const { container } = render(); + const element = container.firstChild as HTMLElement; + + // Element should have data-theme="danger" + expect(element).toHaveAttribute('data-theme', 'danger'); + + // Check computed color - should use boolean selector (higher priority) + // The value selector should be filtered out before CSS generation + const style = window.getComputedStyle(element); + expect(style.color).not.toBe('red'); // Value selector should not apply + }); + + it('should allow both selectors when value has higher priority', () => { + const Component = tasty({ + as: 'div', + styles: { + color: { + theme: 'blue', + 'theme=danger': 'red', // Higher priority after reversal + }, + }, + }); + + const { container } = render(); + const element = container.firstChild as HTMLElement; + + expect(element).toHaveAttribute('data-theme', 'danger'); + // Both CSS rules should be generated, value selector wins via cascade + }); + + it('should handle multiple attributes independently', () => { + const Component = tasty({ + as: 'div', + styles: { + color: { + theme: 'blue', // Boolean higher priority + 'theme=danger': 'red', + }, + fontSize: { + 'size=large': '20px', // Value higher priority + size: '16px', + }, + }, + }); + + const { container } = render( + , + ); + const element = container.firstChild as HTMLElement; + + expect(element).toHaveAttribute('data-theme', 'danger'); + expect(element).toHaveAttribute('data-size', 'large'); + // theme=danger should be filtered, size=large should not + }); + + it('should work with complex combinations', () => { + const Component = tasty({ + as: 'div', + styles: { + color: { + 'theme=danger & hovered': 'lightcoral', + 'theme & hovered': 'lightblue', // Boolean theme higher priority + }, + }, + }); + + const { container } = render( + , + ); + const element = container.firstChild as HTMLElement; + + expect(element).toHaveAttribute('data-theme', 'danger'); + expect(element).toHaveAttribute('data-hovered', ''); + // First combination should be filtered due to boolean theme priority + }); + + it('should not filter when only value selectors exist', () => { + const Component = tasty({ + as: 'div', + styles: { + color: { + 'theme=danger': 'red', + 'theme=warning': 'yellow', + 'theme=success': 'green', + }, + }, + }); + + const { container } = render(); + const element = container.firstChild as HTMLElement; + + expect(element).toHaveAttribute('data-theme', 'danger'); + // All value selectors should remain (no boolean to filter them) + }); + + it('should not filter when only boolean selectors exist', () => { + const Component = tasty({ + as: 'div', + styles: { + color: { + theme: 'blue', + hovered: 'lightblue', + }, + }, + }); + + const { container } = render( + , + ); + const element = container.firstChild as HTMLElement; + + expect(element).toHaveAttribute('data-theme', 'danger'); + expect(element).toHaveAttribute('data-hovered', ''); + // Both boolean selectors should remain + }); + + it('should work with full attribute selector syntax for boolean', () => { + const Component = tasty({ + as: 'button', + styles: { + color: { + '[aria-label="Submit"]': 'red', + '[aria-label]': 'blue', // Higher priority after reversal + }, + }, + }); + + const { container } = render( + Click me, + ); + const element = container.firstChild as HTMLElement; + + expect(element).toHaveAttribute('aria-label', 'Submit'); + + // Check computed color - should use boolean selector (higher priority) + const style = window.getComputedStyle(element); + expect(style.color).not.toBe('red'); // Value selector should not apply + }); + + it('should work with full attribute selector syntax when value has priority', () => { + const Component = tasty({ + as: 'button', + styles: { + color: { + '[aria-label]': 'blue', + '[aria-label="Submit"]': 'red', // Higher priority after reversal + }, + }, + }); + + const { container } = render( + Click me, + ); + const element = container.firstChild as HTMLElement; + + expect(element).toHaveAttribute('aria-label', 'Submit'); + // Both CSS rules should be generated, value selector wins via cascade + }); + + it('should work with mixed shorthand and full selector syntax', () => { + const Component = tasty({ + as: 'div', + styles: { + color: { + 'theme=danger': 'red', + '[data-theme]': 'blue', // Higher priority after reversal + }, + }, + }); + + const { container } = render(); + const element = container.firstChild as HTMLElement; + + expect(element).toHaveAttribute('data-theme', 'danger'); + + // Check computed color - should use boolean selector (higher priority) + const style = window.getComputedStyle(element); + expect(style.color).not.toBe('red'); // Value selector should not apply + }); + + it('should work with data-* prefix in full selector syntax', () => { + const Component = tasty({ + as: 'div', + styles: { + fontSize: { + '[data-size="large"]': '20px', + '[data-size]': '16px', // Higher priority after reversal + }, + }, + }); + + const { container } = render(); + const element = container.firstChild as HTMLElement; + + expect(element).toHaveAttribute('data-size', 'large'); + + // Check computed fontSize - should use boolean selector (higher priority) + const style = window.getComputedStyle(element); + expect(style.fontSize).not.toBe('20px'); // Value selector should not apply + }); + }); + + describe('Migration from data-is-* to data-*', () => { + it('should generate data-disabled instead of data-is-disabled', () => { + const DisabledElement = tasty({ + as: 'div', + styles: {}, + }); + + const { container } = render( + Content, + ); + expect(container.firstChild).toHaveAttribute('disabled'); + expect(container.firstChild).toHaveAttribute('data-disabled', ''); + }); + + it('should generate data-checked instead of data-is-checked', () => { + const CheckboxElement = tasty({ + as: 'input', + styles: {}, + }); + + const { container } = render( + , + ); + expect(container.firstChild).toHaveAttribute('checked'); + expect(container.firstChild).toHaveAttribute('data-checked', ''); + }); + + it('should generate data-hidden instead of data-is-hidden', () => { + const HiddenElement = tasty({ + as: 'div', + styles: {}, + }); + + const { container } = render( + Content, + ); + expect(container.firstChild).toHaveAttribute('hidden'); + expect(container.firstChild).toHaveAttribute('data-hidden', ''); + }); + }); + + describe(':has() selector transformation', () => { + describe('getModSelector unit tests', () => { + it('should transform :has(Item) to :has([data-element="Item"])', () => { + expect(getModSelector(':has(Item)')).toBe( + ':has([data-element="Item"])', + ); + }); + + it('should transform :has(Body > Row) with multiple elements', () => { + expect(getModSelector(':has(Body > Row)')).toBe( + ':has([data-element="Body"] > [data-element="Row"])', + ); + }); + + it('should preserve non-capitalized selectors in :has()', () => { + expect(getModSelector(':has(.selected)')).toBe(':has(.selected)'); + }); + + it('should handle mixed selectors :has(.class Item)', () => { + expect(getModSelector(':has(.wrapper Item)')).toBe( + ':has(.wrapper [data-element="Item"])', + ); + }); + + it('should handle :has() with combinators', () => { + expect(getModSelector(':has(> Item)')).toBe( + ':has(> [data-element="Item"])', + ); + }); + + it('should handle multiple capitalized elements with spaces', () => { + expect(getModSelector(':has(Body Item Row)')).toBe( + ':has([data-element="Body"] [data-element="Item"] [data-element="Row"])', + ); + }); + + it('should warn when combinator lacks spaces in :has()', () => { + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + // Test various invalid patterns + getModSelector(':has(Body>Row)'); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('[Tasty] Invalid :has() selector syntax'), + ); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining(':has(Body>Row)'), + ); + + consoleErrorSpy.mockClear(); + + getModSelector(':has(Header+Content)'); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('[Tasty] Invalid :has() selector syntax'), + ); + + consoleErrorSpy.mockClear(); + + getModSelector(':has(List~Item)'); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('[Tasty] Invalid :has() selector syntax'), + ); + + consoleErrorSpy.mockRestore(); + }); + + it('should not warn when combinator has proper spaces in :has()', () => { + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + getModSelector(':has(Body > Row)'); + getModSelector(':has(Header + Content)'); + getModSelector(':has(List ~ Item)'); + getModSelector(':has(> Item)'); + + expect(consoleErrorSpy).not.toHaveBeenCalled(); + + consoleErrorSpy.mockRestore(); + }); + + it('should transform type^=fullscreen to [data-type^="fullscreen"]', () => { + expect(getModSelector('type^=fullscreen')).toBe( + '[data-type^="fullscreen"]', + ); + }); + + it('should transform type$=screen to [data-type$="screen"]', () => { + expect(getModSelector('type$=screen')).toBe('[data-type$="screen"]'); + }); + + it('should transform type*=full to [data-type*="full"]', () => { + expect(getModSelector('type*=full')).toBe('[data-type*="full"]'); + }); + + it('should handle quoted values with ^= operator', () => { + expect(getModSelector('type^="fullscreen"')).toBe( + '[data-type^="fullscreen"]', + ); + }); + + it('should handle single-quoted values with $= operator', () => { + expect(getModSelector("type$='screen'")).toBe('[data-type$="screen"]'); + }); + + it('should handle quoted values with *= operator', () => { + expect(getModSelector('name*="test"')).toBe('[data-name*="test"]'); + }); + + it('should convert camelCase keys with operators', () => { + expect(getModSelector('dataType^=fullscreen')).toBe( + '[data-data-type^="fullscreen"]', + ); + }); + }); + + it('should transform :has(Item) to :has([data-element="Item"]) in component', () => { + const Container = tasty({ + as: 'div', + styles: { + padding: { + '': '1x', + ':has(Item)': '2x', + }, + }, + }); + + const Item = tasty({ + as: 'div', + 'data-element': 'Item', + styles: {}, + }); + + const { container } = render( + + Child + , + ); + + const element = container.firstChild as HTMLElement; + + // Verify the elements are rendered + expect(element).toBeInTheDocument(); + expect( + element.querySelector('[data-element="Item"]'), + ).toBeInTheDocument(); + }); + + it('should transform :has(Body > Row) with multiple elements', () => { + const Container = tasty({ + as: 'div', + styles: { + display: { + '': 'block', + ':has(Body > Row)': 'flex', + }, + }, + }); + + const Body = tasty({ + as: 'div', + 'data-element': 'Body', + styles: {}, + }); + + const Row = tasty({ + as: 'div', + 'data-element': 'Row', + styles: {}, + }); + + const { container } = render( + + + Content + + , + ); + + const element = container.firstChild as HTMLElement; + + // The element should have styles applied + expect(element).toBeInTheDocument(); + }); + + it('should preserve non-capitalized selectors in :has()', () => { + const Container = tasty({ + as: 'div', + styles: { + border: { + '': '1px solid #border', + ':has(.selected)': '2px solid #primary', + }, + }, + }); + + const { container } = render( + +
Child
+
, + ); + + const element = container.firstChild as HTMLElement; + + // The element should be rendered + expect(element).toBeInTheDocument(); + }); + + it('should handle mixed selectors :has(.class Item)', () => { + const Container = tasty({ + as: 'div', + styles: { + background: { + '': '#surface', + ':has(.wrapper Item)': '#primary', + }, + }, + }); + + const Item = tasty({ + as: 'div', + 'data-element': 'Item', + styles: {}, + }); + + const { container } = render( + +
+ Content +
+
, + ); + + const element = container.firstChild as HTMLElement; + + // The element should be rendered with styles + expect(element).toBeInTheDocument(); + }); + }); +});