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
38 changes: 27 additions & 11 deletions packages/component/src/lib/style/lib/style.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export interface CSSProps extends DOMStyleProperties {
[key: string]: CSSProps | string | number | null | undefined
}

type StyleObject = Record<string, unknown>

// Convert camelCase CSS properties to kebab-case
function camelToKebab(str: string): string {
return str.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)
Expand Down Expand Up @@ -94,7 +96,7 @@ function hashStyle(obj: any): string {
}

// Convert style object to CSS text
function styleToCss(styles: CSSProps, selector: string = ''): string {
function styleToCss(styles: StyleObject, selector: string = ''): string {
let baseDeclarations: string[] = []
let nestedBlocks: string[] = []
let atRules: string[] = []
Expand All @@ -103,10 +105,15 @@ function styleToCss(styles: CSSProps, selector: string = ''): string {
for (let [key, value] of Object.entries(styles)) {
if (isComplexSelector(key)) {
if (key.startsWith('@')) {
// Allow at-rules to be conditionally disabled.
// e.g. { '@media (min-width: 600px)': condition ? undefined : { ... } }
let record = toRecord(value)
if (!record) continue

// Some at-rules (e.g., @media) scope declarations to the selector.
// Others (e.g., @function) must NOT include the selector in their body.
if (key.startsWith('@function')) {
let body = atRuleBodyToCss(value as CSSProps)
let body = atRuleBodyToCss(record)
if (body.trim().length > 0) {
preludeAtRules.push(`${key} {\n${indent(body, 2)}\n}`)
} else {
Expand All @@ -115,15 +122,15 @@ function styleToCss(styles: CSSProps, selector: string = ''): string {
} else if (isKeyframesAtRule(key)) {
// Keyframes definitions must not be wrapped with the element selector.
// Emit them before the class rule so animations can be referenced.
let body = keyframesBodyToCss(value)
let body = keyframesBodyToCss(record)
if (body.trim().length > 0) {
preludeAtRules.push(`${key} {\n${indent(body, 2)}\n}`)
} else {
preludeAtRules.push(`${key} {\n}`)
}
} else {
// Default: keep at-rules nested with the element selector
let inner = styleToCss(value as CSSProps, selector)
let inner = styleToCss(record, selector)
if (inner.trim().length > 0) {
atRules.push(`${key} {\n${indent(inner, 2)}\n}`)
} else {
Expand All @@ -135,8 +142,13 @@ function styleToCss(styles: CSSProps, selector: string = ''): string {
}

// For nested selectors, keep them wholesale inside the base block
// Allow nested selectors to be conditionally disabled.
// e.g. { '&:hover': condition ? undefined : { ... } }
let record = toRecord(value)
if (!record) continue

let nestedContent = ''
for (let [prop, propValue] of Object.entries(value as Record<string, any>)) {
for (let [prop, propValue] of Object.entries(record)) {
if (propValue != null) {
let normalizedValue = normalizeCssValue(prop, propValue)
nestedContent += ` ${camelToKebab(prop)}: ${normalizedValue};\n`
Expand Down Expand Up @@ -187,13 +199,15 @@ function indent(text: string, spaces: number): string {

// Narrow unknown values to plain record objects
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
return typeof value === 'object' && value !== null && !Array.isArray(value)
}

// Build the body of a @keyframes rule (without wrapping selector)
function keyframesBodyToCss(frames: unknown): string {
if (!isRecord(frames)) return ''
function toRecord(value: unknown): Record<string, unknown> | null {
return isRecord(value) ? value : null
}

// Build the body of a @keyframes rule (without wrapping selector)
function keyframesBodyToCss(frames: StyleObject): string {
let blocks: string[] = []

for (let [frameSelector, frameValue] of Object.entries(frames)) {
Expand Down Expand Up @@ -222,15 +236,17 @@ function keyframesBodyToCss(frames: unknown): string {
}

// Build the body for at-rules that should not include a selector wrapper (e.g., @function)
function atRuleBodyToCss(styles: CSSProps): string {
function atRuleBodyToCss(styles: StyleObject): string {
let declarations: string[] = []
let nested: string[] = []

for (let [key, value] of Object.entries(styles)) {
if (isComplexSelector(key)) {
if (key.startsWith('@')) {
// Nested at-rules inside definition blocks; render their bodies recursively without selectors
let inner = atRuleBodyToCss(value as CSSProps)
let record = toRecord(value)
if (!record) continue
let inner = atRuleBodyToCss(record)
if (inner.trim().length > 0) {
nested.push(`${key} {\n${indent(inner, 2)}\n}`)
} else {
Expand Down
39 changes: 39 additions & 0 deletions packages/component/src/test/vdom.props.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -168,5 +168,44 @@ describe('vnode rendering', () => {
expect(div.className).toBe('second')
expect(div.getAttribute('data-css')).toMatch(/^rmx-/)
})

it('removes nested selector rules when they become undefined', async () => {
let container = document.createElement('div')
document.body.appendChild(container)
let root = createRoot(container)

root.render(
<div
css={{
// Base styling for the child comes from the parent.
'& span': { color: 'rgb(0, 0, 255)' },
// More-specific nested selector is conditionally removed.
'& span.special': { color: 'rgb(255, 0, 0)' },
}}
>
<span className="special">Test</span>
</div>,
)

let child = container.querySelector('span')
invariant(child)

// More-specific nested selector should win.
expect(getComputedStyle(child).color).toBe('rgb(255, 0, 0)')

root.render(
<div
css={{
'& span': { color: 'rgb(0, 0, 255)' },
'& span.special': undefined,
}}
>
<span className="special">Test</span>
</div>,
)

// Once the more-specific selector becomes undefined, the child should fall back to the base rule.
expect(getComputedStyle(child).color).toBe('rgb(0, 0, 255)')
})
})
})