Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .changeset/selector-affix-enhancement.md
Original file line number Diff line number Diff line change
@@ -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"]`.

27 changes: 27 additions & 0 deletions src/stories/Tasty.docs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,33 @@ const Card = tasty({
</Card>
```

#### 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:
Expand Down
60 changes: 60 additions & 0 deletions src/tasty/tasty.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<Card>
<div data-element="Content">Content text</div>
</Card>,
);

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(
<Table>
<div data-element="Body">
<div data-element="Row">
<div data-element="Cell">Cell content</div>
</div>
</div>
</Table>,
);

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',
Expand Down
30 changes: 27 additions & 3 deletions src/tasty/utils/renderStyles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -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;
Expand Down
Loading