diff --git a/.changeset/selector-affix-enhancement.md b/.changeset/selector-affix-enhancement.md new file mode 100644 index 000000000..0d2639075 --- /dev/null +++ b/.changeset/selector-affix-enhancement.md @@ -0,0 +1,6 @@ +--- +"@cube-dev/ui-kit": patch +--- + +Enhanced selector affix syntax (`$`) for sub-element styling in tasty. Capitalized words in the affix are now automatically transformed to sub-element selectors, allowing complex selector chains like `$: '> Body > Row >'` which generates `.table > [data-element="Body"] > [data-element="Row"] > [data-element="Cell"]`. + diff --git a/src/stories/Tasty.docs.mdx b/src/stories/Tasty.docs.mdx index b0dcf10b5..e59365b21 100644 --- a/src/stories/Tasty.docs.mdx +++ b/src/stories/Tasty.docs.mdx @@ -567,6 +567,33 @@ const Card = tasty({ ``` +#### Selector Affix (`$`) + +Control sub-element selector combinator using the `$` property: + +```jsx +const Table = tasty({ + styles: { + // Direct child selector + Row: { + $: '>', // .table > [data-element="Row"] + padding: '1x', + }, + + // Chained selectors (auto-transforms capitalized words) + Cell: { + $: '> Body > Row >', // .table > [data-element="Body"] > [data-element="Row"] > [data-element="Cell"] + border: '1bw solid #border', + }, + + // Default: descendant selector (space) + Text: { + color: '#text', // .table [data-element="Text"] + }, + }, +}); +``` + ### Variants & Theming Create themed component variations: diff --git a/src/tasty/tasty.test.tsx b/src/tasty/tasty.test.tsx index 209f05eac..6989a0a58 100644 --- a/src/tasty/tasty.test.tsx +++ b/src/tasty/tasty.test.tsx @@ -1170,6 +1170,66 @@ describe('tastyGlobal() API', () => { expect(styleContent).not.toMatch(/>\s*\[data-element="Content"\]/); }); + it('should support empty affix for sub-elements with $: ""', () => { + const Card = tasty({ + styles: { + padding: '2x', + Content: { + $: '', + color: '#primary', + }, + }, + }); + + render( + +
Content text
+
, + ); + + const styleElements = document.head.querySelectorAll('[data-tasty]'); + const styleContent = Array.from(styleElements) + .map((el) => el.textContent) + .join(''); + + // Empty affix should result in descendant selector (space only) + expect(styleContent).toMatch(/\s+\[data-element="Content"\]/); + expect(styleContent).not.toMatch(/>\s*\[data-element="Content"\]/); + }); + + it('should support complex selector chain with $: "> Body > Row >"', () => { + const Table = tasty({ + styles: { + display: 'table', + Cell: { + $: '> Body > Row >', + padding: '1x', + border: '1px solid #border', + }, + }, + }); + + render( + +
+
+
Cell content
+
+
+
, + ); + + const styleElements = document.head.querySelectorAll('[data-tasty]'); + const styleContent = Array.from(styleElements) + .map((el) => el.textContent) + .join(''); + + // Should transform to: > [data-element="Body"] > [data-element="Row"] > [data-element="Cell"] + expect(styleContent).toMatch( + />\s*\[data-element="Body"\]\s*>\s*\[data-element="Row"\]\s*>\s*\[data-element="Cell"\]/, + ); + }); + it('should support multiple global style components with different selectors', () => { const GlobalHeading = tasty('h1.special', { preset: 'h1', diff --git a/src/tasty/utils/renderStyles.ts b/src/tasty/utils/renderStyles.ts index dd2b965b1..956e04bb6 100644 --- a/src/tasty/utils/renderStyles.ts +++ b/src/tasty/utils/renderStyles.ts @@ -72,6 +72,27 @@ export function isSelector(key: string): boolean { ); } +/** + * Transform selector affix by converting capitalized words to sub-element selectors. + * Returns a selector prefix with leading and trailing spaces. + * + * Examples: + * '> Body > Row' -> ' > [data-element="Body"] > [data-element="Row"] ' + * '> Body > Row >' -> ' > [data-element="Body"] > [data-element="Row"] > ' + * '>' -> ' > ' + */ +function transformSelectorAffix(affix: string): string { + const trimmed = affix.trim(); + if (!trimmed) return ' '; + + const tokens = trimmed.split(/\s+/); + const transformed = tokens.map((token) => + /^[A-Z]/.test(token) ? `[data-element="${token}"]` : token, + ); + + return ` ${transformed.join(' ')} `; +} + /** * Get the selector suffix for a key */ @@ -85,9 +106,12 @@ function getSelector(key: string, styles?: Styles): string | null { } if (key.match(/^[A-Z]/)) { - // Check if styles object has $: '>' for direct child selector - const combinator = styles && (styles as any).$ === '>' ? ' > ' : ' '; - return `${combinator}[data-element="${key}"]`; + const affix = styles?.$; + if (affix !== undefined) { + const prefix = transformSelectorAffix(String(affix)); + return `${prefix}[data-element="${key}"]`; + } + return ` [data-element="${key}"]`; } return null;